Browse Source

feat(marketplace): add v3 registry support

Alvin Unreal 4 days ago
parent
commit
da1e09a1dd

+ 17 - 9
README.md

@@ -115,13 +115,17 @@ npx oh-my-opencode-slim@latest install
 
 Startup and local marketplace reads are offline. Explicit registry install and
 update operations use bounded HTTPS requests to the fixed beta registry.
-Package manifests are data-only, exact-version locked, and stored under the XDG data directory.
-Installation and preset activation are separate: install a package, then enable
-it in the active preset. Activated agents apply after the next
+Package manifests are data-only, exact-version locked, and stored under the XDG
+data directory. The CLI registry `install` command installs a package and
+enables its agent in the active preset in one operation. Local imports still
+require a separate activation step. Activated agents apply after the next
 OpenCode session/reload; the live registry is never hot-swapped.
 Manifests include bounded author, tag, license, plugin compatibility, routing,
-model policy, exact skills/MCPs/tools, and prompt metadata. Agents may extend
-one built-in specialist with explicit append/replace composition semantics.
+model policy, exact skills/MCPs/tools, and prompt metadata. Schema-v2 manifests
+retain the legacy routing fields; schema-v3 manifests use bounded single-line
+`lane`, `stats`, `delegateWhen`, `avoid`, and optional
+`additionalInstructions` fields. V3 extensions are append-only. Agents may
+extend one built-in specialist with the version's composition semantics.
 Executable fields,
 package-to-package dependencies, and arbitrary file maps are rejected.
 
@@ -141,10 +145,14 @@ bunx oh-my-opencode-slim marketplace status
 
 Use `import` explicitly for local author files; `import --update` requires an
 existing package and a strictly newer version. Unversioned registry install
-selects the highest compatible version, while `ID@version` selects exactly
-that version. Registry update requires an installed package and is strictly
-monotonic. `enable` activates an installed agent package in the active preset as
-a separately named agent. Declared skills and MCPs are preflighted against
+selects the highest compatible version and enables it in the active preset,
+while `ID@version` selects exactly that version. Registry update requires an
+installed package and is strictly monotonic. `enable` activates an already
+installed agent package in the active preset as a separately named agent.
+Registry installs try v3 first and fall back to v2 only when v3 is unavailable
+or lacks the requested package; malformed or integrity-invalid v3 data is not
+downgraded.
+Declared skills and MCPs are preflighted against
 built-in capabilities and on-disk host configuration; missing dependencies
 disable that agent for the session.
 Startup reads only the local store and never contacts a registry. The

+ 34 - 18
docs/marketplace.md

@@ -5,10 +5,11 @@ read are offline. Only explicit registry `install` and `update` operations
 make bounded HTTPS requests to the beta registry.
 
 Package manifests are data-only, exact-version locked, and stored under the
-XDG data directory. Installation and preset activation are separate: install
-a package, then enable it in the active preset. Activated agents apply after
-the next OpenCode session or reload. The live agent
-registry is never hot-swapped.
+XDG data directory. The CLI registry `install` command installs a package and
+enables its agent in the active preset in one operation. Local imports and
+in-session tool actions remain separate from preset activation. Activated agents
+apply after the next OpenCode session or reload. The live agent registry is
+never hot-swapped.
 
 ## CLI
 
@@ -30,10 +31,13 @@ bunx oh-my-opencode-slim marketplace status [--json]
 `import` is the explicit local author workflow and records the canonical
 absolute local source path in the lockfile. Add `--update` to import a strictly
 newer version into an existing package. Registry `install` accepts an ID or an
-exact `ID@version`; an unversioned ID selects the highest compatible version.
+exact `ID@version`; an unversioned ID selects the highest compatible version and
+enables the installed agent in the active preset. Registry installs try the v3
+endpoint first and use v2 only when v3 is unavailable or does not contain the
+requested package; malformed or integrity-invalid v3 data is not downgraded.
 Registry `update` requires an installed package and selects only a strictly
-newer compatible version. `enable` activates an installed agent package in the
-active preset as a separately named agent. An agent may optionally extend one
+newer compatible version. `enable` activates an already installed agent package
+in the active preset as a separately named agent. An agent may optionally extend one
 built-in specialist.
 
 `status` reports installed packages, configured activation, live-session
@@ -44,6 +48,14 @@ missing required dependency, invalid alias). Operational failures leave
 in-session `reload_required` as `unknown` because disk identity cannot be
 compared.
 
+## Manifest routing versions
+
+Schema-v2 manifests retain the legacy routing object and prompt-mode behavior.
+Schema-v3 manifests use a deterministic routing object with `lane`, `stats`,
+`delegateWhen`, `avoid`, and optional `additionalInstructions`; extensions are
+append-only. V3 routing lines are single-line bounded values, and list order is
+preserved in the generated routing block.
+
 ## In-session tool
 
 The orchestrator can use the `marketplace` tool for the same lifecycle. Its
@@ -74,7 +86,8 @@ read (for example EACCES).
 
 ## Limits
 
-- The beta registry is fixed at `https://registry.ohmyopencodeslim.com/v2/`;
+- The beta registry uses `https://registry.ohmyopencodeslim.com/v3/` first and
+  falls back to `/v2/` only for an unavailable or missing v3 package;
   configurable registries and redirects are not supported.
 - Declared skills and MCPs are preflighted against built-in capabilities and
   on-disk host configuration. Missing dependencies disable that package for
@@ -85,13 +98,16 @@ read (for example EACCES).
 ## Registry contract
 
 Registry CI and static site tooling can import the narrow
-`oh-my-opencode-slim/marketplace-contract` package subpath. It provides the
-schema-v3 indexes (including retirement tombstones), deterministic
-artifact paths, manifest-summary projection, selector resolution, and the same
-canonical bundle SHA-256 digest used by the plugin store. It also exports
-`renderDefaultMarketplaceAutoDelegationBlock(manifest)`, the authoritative
-default routing block for marketplace agents. Built-in extensions use the
-current built-in role routing plus the package's routing suffix; standalone
-packages use a generic lane block. This default renderer does not apply owner
-or runtime display-alias overrides. The public contract does not add
-root-package exports.
+`oh-my-opencode-slim/marketplace-contract` package subpath. The fixed `/v2/`
+registry remains a v2-manifest index and must be parsed with its v2 parser.
+The contract also exposes separate v3 manifest/index schemas, parsers, summary
+projection, selector resolution, and the future `/v3/` registry base URL;
+v2 parsing never accepts v3 artifacts. Both contracts use deterministic
+artifact paths, retirement tombstones, and canonical bundle SHA-256 digests.
+It also exports `renderDefaultMarketplaceAutoDelegationBlock(manifest)`, the
+authoritative deterministic routing block. Built-in v3 extensions use the
+current built-in role routing followed by package lane, stats, delegation, and
+avoidance guidance; standalone v3 packages include their role sentence and
+mechanically derived declared capabilities. This default renderer does not
+apply owner or runtime display-alias overrides. The public contract does not
+add root-package exports.

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "oh-my-opencode-slim",
-  "version": "3.0.0-beta.4",
+  "version": "3.0.0-beta.6",
   "packageManager": "bun@1.3.14",
   "description": "Lightweight agent orchestration plugin for OpenCode - a slimmed-down fork of oh-my-opencode",
   "main": "dist/index.js",

+ 70 - 0
scripts/verify-release-artifact.ts

@@ -283,6 +283,76 @@ function verifyFreshInstall(tarballPath: string) {
     run('node', ['--input-type=module', '--eval', contractSmokeScript], {
       cwd: installDir,
     });
+
+    const marketplaceSmokeScript = [
+      "import pkg from 'oh-my-opencode-slim';",
+      "import { createMarketplaceRegistryEntryV3, createMarketplaceRegistryIndexV3 } from 'oh-my-opencode-slim/marketplace-contract';",
+      `process.env.XDG_DATA_HOME = ${JSON.stringify(path.join(tempRoot, 'marketplace-data'))};`,
+      "const packageId = 'community/release-v3-agent';",
+      "const v3Registry = 'https://registry.ohmyopencodeslim.com/v3/';",
+      "const v2Registry = 'https://registry.ohmyopencodeslim.com/v2/';",
+      "const initial = { manifest: { schemaVersion: 3, id: packageId, version: '1.0.0', displayName: 'Release v3 agent', description: 'Packed v3 release verification agent.', agentName: 'release-v3-agent', prompt: 'Use the release verification role.', author: { name: 'Release verification' }, tags: ['release'], license: 'MIT', compatibility: { plugin: '>=3.0.0-beta.3 <4.0.0' }, routing: { lane: 'Release v3 lane.', stats: ['Packed v3 resolution'], delegateWhen: ['The release v3 package matches.'], avoid: ['The package is unavailable.'] }, skills: [], mcps: [], tools: [], model: { source: 'explicit', candidates: ['provider/model'] } } };",
+      "const updated = { manifest: { ...initial.manifest, version: '2.0.0' } };",
+      'const calls = [];',
+      'let v3IndexReads = 0;',
+      "const json = (value, status = 200) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json' } });",
+      "const v3Artifact = (version) => v3Registry + 'artifacts/' + packageId + '/' + version + '.json';",
+      'globalThis.fetch = async (input) => {',
+      '  const url = String(input);',
+      '  calls.push(url);',
+      "  if (url === v3Registry + 'index.json') {",
+      '    v3IndexReads += 1;',
+      '    const entries = [createMarketplaceRegistryEntryV3(initial)];',
+      '    if (v3IndexReads > 1) entries.push(createMarketplaceRegistryEntryV3(updated));',
+      '    return json(createMarketplaceRegistryIndexV3(entries));',
+      '  }',
+      "  if (url === v3Artifact('1.0.0')) return json(initial);",
+      "  if (url === v3Artifact('2.0.0')) return json(updated);",
+      "  if (url.startsWith(v2Registry)) throw new Error('v2 registry must not be requested: ' + url);",
+      "  throw new Error('Unexpected registry request: ' + url);",
+      '};',
+      'const asyncNoop = async () => ({});',
+      'const client = new Proxy({}, {',
+      '  get(_target, property) {',
+      "    if (property === 'app') return { log: asyncNoop };",
+      "    if (property === 'session') return { abort: asyncNoop };",
+      '    return new Proxy({}, { get: () => asyncNoop });',
+      '  },',
+      '});',
+      'const plugin = await pkg.server({',
+      '  client,',
+      '  directory: process.cwd(),',
+      '  worktree: process.cwd(),',
+      "  serverUrl: new URL('http://127.0.0.1:4096'),",
+      '});',
+      'try {',
+      '  const marketplace = plugin?.tool?.marketplace;',
+      "  if (typeof marketplace?.execute !== 'function') throw new Error('packaged plugin did not register marketplace');",
+      "  const context = { sessionID: 'release-marketplace-smoke', agent: 'orchestrator', abort: new AbortController().signal };",
+      "  const installed = String(await marketplace.execute({ action: 'install', packageId }, context));",
+      "  if (!installed.includes(packageId + '@1.0.0')) throw new Error('v3 install returned an unexpected result: ' + installed);",
+      "  const result = String(await marketplace.execute({ action: 'update', packageId }, context));",
+      "  if (!result.includes(packageId + '@2.0.0')) throw new Error('v3 update returned an unexpected result: ' + result);",
+      "  const shown = String(await marketplace.execute({ action: 'show', packageId }, context));",
+      "  const sourceLine = shown.split('\\n').find((line) => line.startsWith('source: '));",
+      "  if (!sourceLine) throw new Error('marketplace show omitted provenance: ' + shown);",
+      "  const source = JSON.parse(sourceLine.slice('source: '.length));",
+      "  const expectedSource = { kind: 'registry', registry: v3Registry, indexUrl: v3Registry + 'index.json', packageUrl: v3Artifact('2.0.0') };",
+      "  if (JSON.stringify(source) !== JSON.stringify(expectedSource)) throw new Error('v3 provenance was not retained: ' + JSON.stringify(source));",
+      "  if (v3IndexReads !== 2) throw new Error('expected two v3 index reads, got ' + v3IndexReads);",
+      "  const expectedCalls = [ v3Registry + 'index.json', v3Artifact('1.0.0'), v3Registry + 'index.json', v3Artifact('2.0.0') ];",
+      "  if (JSON.stringify(calls) !== JSON.stringify(expectedCalls)) throw new Error('unexpected marketplace registry calls: ' + JSON.stringify(calls));",
+      "  console.log('packed marketplace v3 install/update passed');",
+      '} finally {',
+      '  await plugin.dispose?.();',
+      '}',
+    ].join('\n');
+    console.log(
+      'Exercising marketplace v3 install/update from the packed package...',
+    );
+    run('node', ['--input-type=module', '--eval', marketplaceSmokeScript], {
+      cwd: installDir,
+    });
   } finally {
     rmSync(tempRoot, { recursive: true, force: true });
   }

+ 3 - 1
src/agents/index.ts

@@ -1155,7 +1155,9 @@ function buildRoutingEntriesFromAgents(
       ? renderMarketplaceAutoDelegationBlock(
           marketplaceManifest,
           runtimeName,
-          agent.description,
+          marketplaceManifest.schemaVersion === 3
+            ? undefined
+            : agent.description,
         )
       : genericRoutingBlock;
     return [

+ 2 - 0
src/cli/index.ts

@@ -77,6 +77,7 @@ Usage:
   bunx oh-my-opencode-slim install [OPTIONS]
   bunx oh-my-opencode-slim doctor [OPTIONS]
   bunx oh-my-opencode-slim marketplace install <publisher/package[@version]>
+                           Install and enable the package in the active preset
   bunx oh-my-opencode-slim marketplace import <package.json> [--update]
   bunx oh-my-opencode-slim marketplace update <publisher/package>
   bunx oh-my-opencode-slim marketplace list
@@ -84,6 +85,7 @@ Usage:
   bunx oh-my-opencode-slim marketplace verify [package-id] [--json]
   bunx oh-my-opencode-slim marketplace remove <package-id> [--force]
   bunx oh-my-opencode-slim marketplace enable <package-id>
+                           Enable an already installed package
   bunx oh-my-opencode-slim marketplace disable <package-id>
   bunx oh-my-opencode-slim marketplace status [--json]
 

+ 166 - 1
src/cli/marketplace.test.ts

@@ -1,5 +1,47 @@
 import { describe, expect, test } from 'bun:test';
-import { parseMarketplaceArgs } from './marketplace';
+import {
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { MarketplaceStore } from '../marketplace/store';
+import {
+  createMarketplaceRegistryEntry,
+  type MarketplacePackageBundle,
+} from '../marketplace-contract';
+import { marketplaceCommand, parseMarketplaceArgs } from './marketplace';
+
+function bundle(): MarketplacePackageBundle {
+  return {
+    manifest: {
+      schemaVersion: 2,
+      id: 'community/example',
+      version: '1.2.3',
+      displayName: 'Example agent',
+      description: 'A CLI test package.',
+      agentName: 'exampleagent',
+      prompt: 'Use the explorer role.',
+      author: { name: 'Community' },
+      tags: ['test'],
+      license: 'MIT',
+      compatibility: { plugin: '>=3.0.0' },
+      routing: {
+        description: 'Handle CLI test requests.',
+        keywords: ['test'],
+        when: 'When running CLI tests.',
+      },
+      skills: [],
+      mcps: [],
+      tools: [],
+      model: { source: 'explicit', candidates: ['provider/model'] },
+    },
+  };
+}
 
 describe('marketplace CLI parsing', () => {
   test('keeps local import distinct from registry install/update', () => {
@@ -59,4 +101,127 @@ describe('marketplace CLI parsing', () => {
       json: true,
     });
   });
+
+  test('installs and enables a registry package in one command', async () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-cli-'));
+    const configDir = join(root, 'config');
+    const projectDir = join(root, 'project');
+    const storeRoot = join(root, 'store');
+    const configPath = join(configDir, 'oh-my-opencode-slim.json');
+    const originalConfigDir = process.env.OPENCODE_CONFIG_DIR;
+    const output: string[] = [];
+    const originalLog = console.log;
+    const selectors: string[] = [];
+    try {
+      process.env.OPENCODE_CONFIG_DIR = configDir;
+      mkdirSync(configDir, { recursive: true });
+      mkdirSync(projectDir, { recursive: true });
+      writeFileSync(
+        configPath,
+        JSON.stringify({
+          preset: 'work',
+          presets: { work: { agents: {} } },
+        }),
+      );
+      console.log = (...args: unknown[]) => {
+        output.push(args.map(String).join(' '));
+      };
+
+      const packageBundle = bundle();
+      const exitCode = await marketplaceCommand(
+        ['install', 'community/example'],
+        {
+          projectDir,
+          rootDir: storeRoot,
+          pluginVersion: '3.1.0',
+          registryClient: {
+            download: async (selector) => {
+              selectors.push(selector);
+              return {
+                bundle: packageBundle,
+                entry: createMarketplaceRegistryEntry(packageBundle),
+                indexUrl: 'https://registry.example/index.json',
+                packageUrl: 'https://registry.example/artifact.json',
+              };
+            },
+          },
+        },
+      );
+
+      expect(exitCode).toBe(0);
+      expect(selectors).toEqual(['community/example']);
+      expect(
+        JSON.parse(readFileSync(configPath, 'utf8')).presets.work.marketplace,
+      ).toEqual({ agents: ['community/example'] });
+      expect(output[0]).toContain(
+        'Installed and enabled community/example@1.2.3 in the active preset',
+      );
+    } finally {
+      console.log = originalLog;
+      if (originalConfigDir === undefined) {
+        delete process.env.OPENCODE_CONFIG_DIR;
+      } else {
+        process.env.OPENCODE_CONFIG_DIR = originalConfigDir;
+      }
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('preflights activation before one-shot install mutates the store', async () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-cli-preflight-'));
+    const configDir = join(root, 'config');
+    const projectDir = join(root, 'project');
+    const storeRoot = join(root, 'store');
+    const configPath = join(configDir, 'oh-my-opencode-slim.json');
+    const originalConfigDir = process.env.OPENCODE_CONFIG_DIR;
+    const originalError = console.error;
+    let downloads = 0;
+    try {
+      process.env.OPENCODE_CONFIG_DIR = configDir;
+      mkdirSync(configDir, { recursive: true });
+      mkdirSync(projectDir, { recursive: true });
+      writeFileSync(
+        configPath,
+        JSON.stringify({
+          preset: 'missing',
+          presets: { work: { agents: {} } },
+        }),
+      );
+      console.error = () => {};
+
+      const exitCode = await marketplaceCommand(
+        ['install', 'community/example'],
+        {
+          projectDir,
+          rootDir: storeRoot,
+          pluginVersion: '3.0.0-beta.6',
+          registryClient: {
+            download: async () => {
+              downloads += 1;
+              return {
+                bundle: bundle(),
+                entry: createMarketplaceRegistryEntry(bundle()),
+                indexUrl: 'https://registry.example/index.json',
+                packageUrl: 'https://registry.example/artifact.json',
+              };
+            },
+          },
+        },
+      );
+
+      expect(exitCode).toBe(1);
+      expect(downloads).toBe(0);
+      const store = new MarketplaceStore({ rootDir: storeRoot });
+      expect(store.list()).toEqual([]);
+      expect(existsSync(store.paths.lockfilePath)).toBe(false);
+    } finally {
+      console.error = originalError;
+      if (originalConfigDir === undefined) {
+        delete process.env.OPENCODE_CONFIG_DIR;
+      } else {
+        process.env.OPENCODE_CONFIG_DIR = originalConfigDir;
+      }
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
 });

+ 5 - 1
src/cli/marketplace.ts

@@ -5,6 +5,7 @@ import {
 import {
   disableMarketplacePackage,
   enableMarketplaceAgent,
+  preflightMarketplaceAgentActivation,
 } from '../marketplace/activation-config';
 import {
   collectMarketplaceStatus,
@@ -125,10 +126,13 @@ export async function marketplaceCommand(
     const projectDir = options.projectDir ?? process.cwd();
     switch (parsed.command) {
       case 'install': {
+        const packageId = (parsed.value as string).trim().split('@', 1)[0];
+        preflightMarketplaceAgentActivation(projectDir, packageId);
         const pkg = await service.installRemote(parsed.value as string);
+        enableMarketplaceAgent(projectDir, pkg.manifest.id, service.store);
         console.log(
           mutationReloadNotice(
-            `Installed ${pkg.manifest.id}@${pkg.manifest.version}`,
+            `Installed and enabled ${pkg.manifest.id}@${pkg.manifest.version} in the active preset`,
             'unknown',
           ),
         );

+ 27 - 1
src/marketplace-contract/index.test.ts

@@ -1,7 +1,13 @@
 import { describe, expect, test } from 'bun:test';
 import { ROLE_DEFINITIONS } from '../agents/role-definitions';
 import type { MarketplacePackageManifest } from '../marketplace/schemas';
-import { renderDefaultMarketplaceAutoDelegationBlock } from './index';
+import {
+  createMarketplaceRegistryEntryV3,
+  createMarketplaceRegistryIndexV3,
+  parseMarketplaceRegistryIndex,
+  parseMarketplaceRegistryIndexV3,
+  renderDefaultMarketplaceAutoDelegationBlock,
+} from './index';
 
 const manifest: MarketplacePackageManifest = {
   schemaVersion: 2,
@@ -38,4 +44,24 @@ describe('marketplace contract routing export', () => {
       expected,
     );
   });
+
+  test('selects the explicit v3 registry contract without widening v2 parsing', () => {
+    const v3Manifest = {
+      ...manifest,
+      schemaVersion: 3 as const,
+      routing: {
+        lane: 'Contract lane.',
+        stats: ['Fast'],
+        delegateWhen: ['The contract task matches.'],
+        avoid: ['Unbounded work.'],
+      },
+    };
+    const entry = createMarketplaceRegistryEntryV3({
+      manifest: v3Manifest,
+    });
+    const index = createMarketplaceRegistryIndexV3([entry]);
+
+    expect(parseMarketplaceRegistryIndexV3(index)).toEqual(index);
+    expect(() => parseMarketplaceRegistryIndex(index)).toThrow();
+  });
 });

+ 294 - 7
src/marketplace-contract/index.ts

@@ -11,26 +11,43 @@ import { isMarketplacePackageRetired } from '../marketplace/retirements';
 import { renderMarketplaceAutoDelegationBlock } from '../marketplace/routing';
 import {
   MARKETPLACE_DIGEST_DOMAIN,
+  MARKETPLACE_DIGEST_DOMAIN_V3,
   MARKETPLACE_MANIFEST_SCHEMA_VERSION,
+  MARKETPLACE_MANIFEST_SCHEMA_VERSION_V3,
   MarketplaceAgentManifestSchema,
   MarketplaceAgentManifestSummarySchema,
+  MarketplaceAgentManifestSummaryV2Schema,
+  MarketplaceAgentManifestSummaryV3Schema,
   type MarketplaceDigest,
   MarketplaceDigestSchema,
+  type MarketplaceDigestV3,
+  MarketplaceDigestV3Schema,
+  MarketplaceExtensionV3Schema,
   type MarketplacePackageBundle,
   MarketplacePackageBundleSchema,
+  MarketplacePackageBundleV2Schema,
+  MarketplacePackageBundleV3Schema,
   type MarketplacePackageId,
   MarketplacePackageIdSchema,
   type MarketplacePackageManifest,
   MarketplacePackageManifestSchema,
+  type MarketplacePackageManifestV2,
+  MarketplacePackageManifestV2Schema,
+  type MarketplacePackageManifestV3,
+  MarketplacePackageManifestV3Schema,
+  MarketplaceRoutingV3Schema,
   type MarketplaceVersion,
   MarketplaceVersionSchema,
 } from '../marketplace/schemas';
 
 export type {
   MarketplaceDigest,
+  MarketplaceDigestV3,
   MarketplacePackageBundle,
   MarketplacePackageId,
   MarketplacePackageManifest,
+  MarketplacePackageManifestV2,
+  MarketplacePackageManifestV3,
   MarketplaceVersion,
 };
 export {
@@ -39,12 +56,24 @@ export {
   compareMarketplaceCodeUnits,
   digestMarketplaceBundle,
   MARKETPLACE_DIGEST_DOMAIN,
+  MARKETPLACE_DIGEST_DOMAIN_V3,
   MARKETPLACE_MANIFEST_SCHEMA_VERSION,
+  MARKETPLACE_MANIFEST_SCHEMA_VERSION_V3,
   MarketplaceAgentManifestSchema,
+  MarketplaceAgentManifestSummarySchema,
+  MarketplaceAgentManifestSummaryV2Schema,
+  MarketplaceAgentManifestSummaryV3Schema,
   MarketplaceDigestSchema,
+  MarketplaceDigestV3Schema,
+  MarketplaceExtensionV3Schema,
   MarketplacePackageBundleSchema,
+  MarketplacePackageBundleV2Schema,
+  MarketplacePackageBundleV3Schema,
   MarketplacePackageIdSchema,
   MarketplacePackageManifestSchema,
+  MarketplacePackageManifestV2Schema,
+  MarketplacePackageManifestV3Schema,
+  MarketplaceRoutingV3Schema,
   MarketplaceVersionSchema,
 };
 
@@ -62,12 +91,23 @@ export function renderDefaultMarketplaceAutoDelegationBlock(
 export const MARKETPLACE_REGISTRY_SCHEMA_VERSION = 3 as const;
 export const DEFAULT_MARKETPLACE_REGISTRY_URL =
   'https://registry.ohmyopencodeslim.com/v2/' as const;
+export const DEFAULT_MARKETPLACE_REGISTRY_V3_URL =
+  'https://registry.ohmyopencodeslim.com/v3/' as const;
+export const DEFAULT_MARKETPLACE_REGISTRY_URL_V3 =
+  DEFAULT_MARKETPLACE_REGISTRY_V3_URL;
+export const MARKETPLACE_REGISTRY_V3_SCHEMA_VERSION = 3 as const;
 
-const MarketplaceAgentSummarySchema = MarketplaceAgentManifestSummarySchema;
+// The v2 endpoint intentionally remains v2-only. In particular, changing the
+// union used by package parsing must not make old registry indexes accept v3.
+const MarketplaceAgentSummarySchema = MarketplaceAgentManifestSummaryV2Schema;
 
 /** Public catalog metadata; package prompts never enter the index. */
 export const MarketplaceManifestSummarySchema = MarketplaceAgentSummarySchema;
 
+/** Public catalog metadata for the future v3 registry endpoint. */
+export const MarketplaceManifestSummaryV3Schema =
+  MarketplaceAgentManifestSummaryV3Schema;
+
 export const MarketplaceRegistryEntrySchema = z
   .object({
     id: MarketplacePackageIdSchema,
@@ -89,12 +129,38 @@ export const MarketplaceRegistryEntrySchema = z
   })
   .strict();
 
+export const MarketplaceRegistryEntryV3Schema = z
+  .object({
+    id: MarketplacePackageIdSchema,
+    version: MarketplaceVersionSchema,
+    artifactPath: z
+      .string()
+      .regex(
+        /^artifacts\/[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,63}\/[0-9A-Za-z.+-]+\.json$/,
+        'Expected a deterministic registry artifact path',
+      ),
+    digest: z
+      .object({
+        algorithm: z.literal('sha256'),
+        domain: z.literal(MARKETPLACE_DIGEST_DOMAIN_V3),
+        value: z.string().regex(/^[0-9a-f]{64}$/),
+      })
+      .strict(),
+    summary: MarketplaceManifestSummaryV3Schema,
+  })
+  .strict();
+
 export const MarketplaceRegistryRetirementSchema = z
   .object({ id: MarketplacePackageIdSchema })
   .strict();
 
 function validateRegistryEntries(
-  entries: readonly MarketplaceRegistryEntry[],
+  entries: readonly {
+    id: string;
+    version: string;
+    artifactPath: string;
+    summary: { id: string; version: string };
+  }[],
   ctx: z.RefinementCtx,
 ): void {
   const seen = new Set<string>();
@@ -151,7 +217,7 @@ const CANONICAL_MARKETPLACE_RETIREMENT_IDS = [
   'alvin/deepwork-reviewer',
 ] as const;
 
-const MarketplaceRegistryIndexV3Schema = z
+const MarketplaceRegistryIndexV2EndpointSchema = z
   .object({
     schemaVersion: z.literal(MARKETPLACE_REGISTRY_SCHEMA_VERSION),
     entries: z.array(MarketplaceRegistryEntrySchema).max(100_000),
@@ -194,20 +260,84 @@ const MarketplaceRegistryIndexV3Schema = z
     }
   });
 
-export const MarketplaceRegistryIndexSchema = MarketplaceRegistryIndexV3Schema;
+export const MarketplaceRegistryIndexSchema =
+  MarketplaceRegistryIndexV2EndpointSchema;
+export const MarketplaceRegistryEntryV2Schema = MarketplaceRegistryEntrySchema;
+export const MarketplaceRegistryIndexV2Schema = MarketplaceRegistryIndexSchema;
+
+const validateV3Retirements = (
+  retirements: readonly MarketplaceRegistryRetirement[],
+  ctx: z.RefinementCtx,
+): void => {
+  const seen = new Set<string>();
+  for (let position = 0; position < retirements.length; position += 1) {
+    const retirement = retirements[position];
+    if (seen.has(retirement.id)) {
+      ctx.addIssue({
+        code: 'custom',
+        path: ['retirements', position],
+        message: `Duplicate marketplace retirement ${retirement.id}`,
+      });
+    }
+    seen.add(retirement.id);
+    const previous = retirements[position - 1];
+    if (
+      previous &&
+      compareMarketplaceCodeUnits(previous.id, retirement.id) >= 0
+    ) {
+      ctx.addIssue({
+        code: 'custom',
+        path: ['retirements', position],
+        message: 'Marketplace retirements must be sorted by ID',
+      });
+    }
+  }
+  for (const id of CANONICAL_MARKETPLACE_RETIREMENT_IDS) {
+    if (!seen.has(id)) {
+      ctx.addIssue({
+        code: 'custom',
+        path: ['retirements'],
+        message: `Registry must contain canonical retirement ${id}`,
+      });
+    }
+  }
+};
+
+export const MarketplaceRegistryIndexV3Schema = z
+  .object({
+    schemaVersion: z.literal(MARKETPLACE_REGISTRY_V3_SCHEMA_VERSION),
+    entries: z.array(MarketplaceRegistryEntryV3Schema).max(100_000),
+    retirements: z.array(MarketplaceRegistryRetirementSchema).max(100_000),
+  })
+  .strict()
+  .superRefine((index, ctx) => {
+    validateRegistryEntries(index.entries, ctx);
+    validateV3Retirements(index.retirements, ctx);
+  });
 
 export type MarketplaceManifestSummary = z.infer<
   typeof MarketplaceManifestSummarySchema
 >;
+export type MarketplaceManifestSummaryV3 = z.infer<
+  typeof MarketplaceManifestSummaryV3Schema
+>;
 export type MarketplaceRegistryEntry = z.infer<
   typeof MarketplaceRegistryEntrySchema
 >;
+export type MarketplaceRegistryEntryV2 = MarketplaceRegistryEntry;
+export type MarketplaceRegistryEntryV3 = z.infer<
+  typeof MarketplaceRegistryEntryV3Schema
+>;
 export type MarketplaceRegistryRetirement = z.infer<
   typeof MarketplaceRegistryRetirementSchema
 >;
 export type MarketplaceRegistryIndex = z.infer<
   typeof MarketplaceRegistryIndexSchema
 >;
+export type MarketplaceRegistryIndexV2 = MarketplaceRegistryIndex;
+export type MarketplaceRegistryIndexV3 = z.infer<
+  typeof MarketplaceRegistryIndexV3Schema
+>;
 
 export function canonicalizeMarketplaceRegistryIndex(
   index: MarketplaceRegistryIndex,
@@ -223,15 +353,25 @@ export function registryArtifactPath(id: string, version: string): string {
 export function projectMarketplaceManifestSummary(
   manifest: MarketplacePackageManifest,
 ): MarketplaceManifestSummary {
-  const parsed = MarketplacePackageBundleSchema.shape.manifest.parse(manifest);
+  const parsed =
+    MarketplacePackageBundleV2Schema.shape.manifest.parse(manifest);
   const { prompt: _prompt, ...summary } = parsed;
   return summary as MarketplaceManifestSummary;
 }
 
+export function projectMarketplaceManifestSummaryV3(
+  manifest: MarketplacePackageManifest,
+): MarketplaceManifestSummaryV3 {
+  const parsed =
+    MarketplacePackageBundleV3Schema.shape.manifest.parse(manifest);
+  const { prompt: _prompt, ...summary } = parsed;
+  return summary as MarketplaceManifestSummaryV3;
+}
+
 export function createMarketplaceRegistryEntry(
   bundle: MarketplacePackageBundle,
 ): MarketplaceRegistryEntry {
-  const parsed = MarketplacePackageBundleSchema.parse(bundle);
+  const parsed = MarketplacePackageBundleV2Schema.parse(bundle);
   return {
     id: parsed.manifest.id,
     version: parsed.manifest.version,
@@ -248,6 +388,26 @@ export function createMarketplaceRegistryEntry(
   };
 }
 
+export function createMarketplaceRegistryEntryV3(
+  bundle: MarketplacePackageBundle,
+): MarketplaceRegistryEntryV3 {
+  const parsed = MarketplacePackageBundleV3Schema.parse(bundle);
+  return {
+    id: parsed.manifest.id,
+    version: parsed.manifest.version,
+    artifactPath: registryArtifactPath(
+      parsed.manifest.id,
+      parsed.manifest.version,
+    ),
+    digest: {
+      algorithm: 'sha256',
+      domain: MARKETPLACE_DIGEST_DOMAIN_V3,
+      value: digestMarketplaceBundle(parsed),
+    },
+    summary: projectMarketplaceManifestSummaryV3(parsed.manifest),
+  };
+}
+
 export function createMarketplaceRegistryIndex(
   entries: readonly MarketplaceRegistryEntry[],
   retirements: readonly MarketplaceRegistryRetirement[] = CANONICAL_MARKETPLACE_RETIREMENT_IDS.map(
@@ -270,12 +430,40 @@ export function createMarketplaceRegistryIndex(
   });
 }
 
+export function createMarketplaceRegistryIndexV3(
+  entries: readonly MarketplaceRegistryEntryV3[],
+  retirements: readonly MarketplaceRegistryRetirement[] = CANONICAL_MARKETPLACE_RETIREMENT_IDS.map(
+    (id) => ({ id }),
+  ),
+): MarketplaceRegistryIndexV3 {
+  const sorted = [...entries].sort(
+    (left, right) =>
+      compareMarketplaceCodeUnits(left.id, right.id) ||
+      compare(left.version, right.version) ||
+      compareMarketplaceCodeUnits(left.version, right.version),
+  );
+  const sortedRetirements = [...retirements].sort((left, right) =>
+    compareMarketplaceCodeUnits(left.id, right.id),
+  );
+  return parseMarketplaceRegistryIndexV3({
+    schemaVersion: MARKETPLACE_REGISTRY_V3_SCHEMA_VERSION,
+    entries: sorted,
+    retirements: sortedRetirements,
+  });
+}
+
 export function retiredMarketplaceRegistryIds(
   index: MarketplaceRegistryIndex,
 ): ReadonlySet<string> {
   return new Set(index.retirements.map(({ id }) => id));
 }
 
+export function retiredMarketplaceRegistryIdsV3(
+  index: MarketplaceRegistryIndexV3,
+): ReadonlySet<string> {
+  return new Set(index.retirements.map(({ id }) => id));
+}
+
 export function isMarketplaceRegistryIdRetired(
   index: MarketplaceRegistryIndex,
   id: string,
@@ -283,6 +471,13 @@ export function isMarketplaceRegistryIdRetired(
   return retiredMarketplaceRegistryIds(index).has(id);
 }
 
+export function isMarketplaceRegistryIdRetiredV3(
+  index: MarketplaceRegistryIndexV3,
+  id: string,
+): boolean {
+  return retiredMarketplaceRegistryIdsV3(index).has(id);
+}
+
 export function filterMarketplaceRegistryEntries(
   index: MarketplaceRegistryIndex,
 ): MarketplaceRegistryEntry[] {
@@ -290,6 +485,13 @@ export function filterMarketplaceRegistryEntries(
   return index.entries.filter((entry) => !retired.has(entry.id));
 }
 
+export function filterMarketplaceRegistryEntriesV3(
+  index: MarketplaceRegistryIndexV3,
+): MarketplaceRegistryEntryV3[] {
+  const retired = retiredMarketplaceRegistryIdsV3(index);
+  return index.entries.filter((entry) => !retired.has(entry.id));
+}
+
 export function parseMarketplaceRegistryIndex(
   value: unknown,
 ): MarketplaceRegistryIndex {
@@ -302,11 +504,25 @@ export function parseMarketplaceRegistryIndex(
   return result.data;
 }
 
+export const parseMarketplaceRegistryIndexV2 = parseMarketplaceRegistryIndex;
+
+export function parseMarketplaceRegistryIndexV3(
+  value: unknown,
+): MarketplaceRegistryIndexV3 {
+  const result = MarketplaceRegistryIndexV3Schema.safeParse(value);
+  if (!result.success) {
+    throw new Error(
+      `Invalid marketplace v3 registry index: ${result.error.message}`,
+    );
+  }
+  return result.data;
+}
+
 export function validateMarketplaceRegistryEntry(
   entry: MarketplaceRegistryEntry,
   bundle: MarketplacePackageBundle,
 ): void {
-  const parsed = MarketplacePackageBundleSchema.safeParse(bundle);
+  const parsed = MarketplacePackageBundleV2Schema.safeParse(bundle);
   if (!parsed.success)
     throw new Error(`Invalid marketplace artifact: ${parsed.error.message}`);
   const manifest = parsed.data.manifest;
@@ -336,6 +552,40 @@ export function validateMarketplaceRegistryEntry(
   }
 }
 
+export function validateMarketplaceRegistryEntryV3(
+  entry: MarketplaceRegistryEntryV3,
+  bundle: MarketplacePackageBundle,
+): void {
+  const parsed = MarketplacePackageBundleV3Schema.safeParse(bundle);
+  if (!parsed.success)
+    throw new Error(`Invalid marketplace v3 artifact: ${parsed.error.message}`);
+  const manifest = parsed.data.manifest;
+  const summary = projectMarketplaceManifestSummaryV3(manifest);
+  if (
+    manifest.id !== entry.id ||
+    manifest.version !== entry.version ||
+    entry.artifactPath !== registryArtifactPath(entry.id, entry.version)
+  ) {
+    throw new Error(
+      'Marketplace v3 artifact identity does not match registry entry',
+    );
+  }
+  if (
+    canonicalizeMarketplaceValue(summary) !==
+    canonicalizeMarketplaceValue(entry.summary)
+  ) {
+    throw new Error(
+      'Marketplace v3 artifact summary does not match registry entry',
+    );
+  }
+  const digest = digestMarketplaceBundle(parsed.data);
+  if (entry.digest.value !== digest) {
+    throw new Error(
+      'Marketplace v3 artifact digest does not match registry entry',
+    );
+  }
+}
+
 export interface MarketplaceRegistrySelector {
   id: string;
   version?: string;
@@ -395,3 +645,40 @@ export function resolveMarketplaceRegistryEntry(
   }
   return selected;
 }
+
+export function resolveMarketplaceRegistryEntryV3(
+  index: MarketplaceRegistryIndexV3,
+  selector: MarketplaceRegistrySelector,
+  compatibility: { pluginVersion: string },
+  minimumVersion?: string,
+): MarketplaceRegistryEntryV3 {
+  if (
+    isMarketplacePackageRetired(selector.id) ||
+    isMarketplaceRegistryIdRetiredV3(index, selector.id)
+  ) {
+    throw new MarketplaceRetiredError(
+      `${selector.id}${selector.version ? `@${selector.version}` : ''} is retired and cannot be installed`,
+    );
+  }
+  const candidates = filterMarketplaceRegistryEntriesV3(index).filter(
+    (entry) =>
+      entry.id === selector.id &&
+      (selector.version === undefined || entry.version === selector.version) &&
+      (minimumVersion === undefined || gt(entry.version, minimumVersion)) &&
+      satisfies(
+        compatibility.pluginVersion,
+        entry.summary.compatibility.plugin,
+      ),
+  );
+  const selected = [...candidates].sort(
+    (a, b) =>
+      compare(b.version, a.version) ||
+      compareMarketplaceCodeUnits(b.version, a.version),
+  )[0];
+  if (!selected) {
+    throw new Error(
+      `No compatible marketplace v3 package found for ${selector.id}${selector.version ? `@${selector.version}` : ''}`,
+    );
+  }
+  return selected;
+}

+ 27 - 0
src/marketplace/activation-config.ts

@@ -1,3 +1,4 @@
+import { accessSync, constants } from 'node:fs';
 import { mutateJsonFile } from '../cli/config-io';
 import {
   findPluginConfigPaths,
@@ -48,6 +49,31 @@ function activePresetName(config: PluginConfig): string {
   );
 }
 
+export function preflightMarketplaceAgentActivation(
+  directory: string,
+  packageId: string,
+): void {
+  const id = normalizeMarketplacePackageId(packageId);
+  assertMarketplacePackageNotRetired(id);
+  const config = loadPluginConfig(directory, { silent: true });
+  const presetName = activePresetName(config);
+  if (!config.presets?.[presetName]) {
+    throw new MarketplaceActivationError(
+      `Active preset '${presetName}' does not exist in the plugin config`,
+    );
+  }
+  const filePath = configWritePath(directory);
+  try {
+    accessSync(filePath, constants.W_OK);
+  } catch (error) {
+    throw new MarketplaceActivationError(
+      `Cannot write plugin config for marketplace activation: ${
+        error instanceof Error ? error.message : String(error)
+      }`,
+    );
+  }
+}
+
 function cloneActivation(
   activation: MarketplaceActivation | undefined,
 ): MarketplaceActivation {
@@ -118,6 +144,7 @@ export function enableMarketplaceAgent(
 ): void {
   const id = normalizeMarketplacePackageId(packageId);
   assertMarketplacePackageNotRetired(id);
+  preflightMarketplaceAgentActivation(directory, id);
   store.show(id);
   const config = loadPluginConfig(directory, { silent: true });
   const presetName = activePresetName(config);

+ 296 - 1
src/marketplace/registry-client.test.ts

@@ -6,7 +6,9 @@ import type { MarketplacePackageBundle } from '../marketplace-contract';
 import {
   canonicalizeMarketplaceValue,
   createMarketplaceRegistryEntry,
+  createMarketplaceRegistryEntryV3,
   createMarketplaceRegistryIndex,
+  createMarketplaceRegistryIndexV3,
   digestMarketplaceBundle,
   MarketplaceManifestSummarySchema,
   MarketplaceRegistryIndexSchema,
@@ -26,6 +28,7 @@ import {
 import {
   DEFAULT_MARKETPLACE_REGISTRY_ARTIFACT_MAX_BYTES,
   MARKETPLACE_REGISTRY_INDEX_URL,
+  MARKETPLACE_REGISTRY_V3_INDEX_URL,
   MarketplaceRegistryClient,
 } from './registry-client';
 import { MarketplaceService } from './service';
@@ -67,6 +70,26 @@ function bundle(
   };
 }
 
+function bundleV3(
+  version = '1.0.0',
+  id = 'community/registry-v3-agent',
+): MarketplacePackageBundle {
+  const current = bundle(version, id);
+  return {
+    manifest: {
+      ...current.manifest,
+      schemaVersion: 3,
+      compatibility: { plugin: '>=3.0.0-beta.3 <4.0.0' },
+      routing: {
+        lane: 'V3 registry lane.',
+        stats: ['Fast v3 resolution'],
+        delegateWhen: ['The v3 package matches.'],
+        avoid: ['Malformed package metadata.'],
+      },
+    },
+  };
+}
+
 function indexFor(packageBundle = bundle()): Record<string, unknown> {
   const manifest = packageBundle.manifest;
   return {
@@ -88,6 +111,12 @@ function indexFor(packageBundle = bundle()): Record<string, unknown> {
   };
 }
 
+function indexForV3(packageBundle = bundleV3()): Record<string, unknown> {
+  return createMarketplaceRegistryIndexV3([
+    createMarketplaceRegistryEntryV3(packageBundle),
+  ]);
+}
+
 function response(value: unknown, status = 200): Response {
   return new Response(JSON.stringify(value), {
     status,
@@ -242,6 +271,272 @@ describe('marketplace registry contract', () => {
 });
 
 describe('MarketplaceRegistryClient', () => {
+  test('downloads v3 artifacts with the v3 contract and base URL', async () => {
+    const packageBundle = bundleV3();
+    const calls: string[] = [];
+    const client = new MarketplaceRegistryClient({
+      pluginVersion: '3.0.0-beta.6',
+      fetch: async (input) => {
+        const url = String(input);
+        calls.push(url);
+        return url === MARKETPLACE_REGISTRY_V3_INDEX_URL
+          ? response(indexForV3(packageBundle))
+          : response(packageBundle);
+      },
+    });
+
+    const downloaded = await client.downloadV3('community/registry-v3-agent');
+    expect(downloaded.bundle.manifest.schemaVersion).toBe(3);
+    expect(downloaded.registry).toBe(
+      'https://registry.ohmyopencodeslim.com/v3/',
+    );
+    expect(calls).toEqual([
+      MARKETPLACE_REGISTRY_V3_INDEX_URL,
+      'https://registry.ohmyopencodeslim.com/v3/artifacts/community/registry-v3-agent/1.0.0.json',
+    ]);
+  });
+
+  test('the v2 client rejects v3 indexes instead of parsing them as v2', async () => {
+    const packageBundle = bundleV3();
+    const client = new MarketplaceRegistryClient({
+      pluginVersion: '3.0.0-beta.6',
+      fetch: async () => response(indexForV3(packageBundle)),
+    });
+
+    await expect(client.fetchIndex()).rejects.toBeInstanceOf(
+      MarketplaceRegistryProtocolError,
+    );
+  });
+
+  test('MarketplaceService tries v3 first and installs the v3 result', async () => {
+    const packageBundle = bundleV3();
+    const calls: string[] = [];
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-v3-first-'));
+    try {
+      const service = new MarketplaceService({
+        rootDir: root,
+        pluginVersion: '3.0.0-beta.6',
+        registryClient: new MarketplaceRegistryClient({
+          pluginVersion: '3.0.0-beta.6',
+          fetch: async (input) => {
+            const url = String(input);
+            calls.push(url);
+            return url === MARKETPLACE_REGISTRY_V3_INDEX_URL
+              ? response(indexForV3(packageBundle))
+              : response(packageBundle);
+          },
+        }),
+      });
+
+      const installed = await service.installRemote(
+        'community/registry-v3-agent',
+      );
+      expect(installed.manifest.schemaVersion).toBe(3);
+      expect(installed.manifest.version).toBe('1.0.0');
+      expect(
+        service.store.getLockfile().packages['community/registry-v3-agent']
+          .source,
+      ).toMatchObject({
+        kind: 'registry',
+        registry: 'https://registry.ohmyopencodeslim.com/v3/',
+      });
+      expect(calls[0]).toBe(MARKETPLACE_REGISTRY_V3_INDEX_URL);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('updates an installed v3 package through v3 without substituting v2', async () => {
+    const initial = bundleV3('1.0.0');
+    const updated = bundleV3('2.0.0');
+    const v2Replacement = bundle('2.0.0', initial.manifest.id);
+    const calls: string[] = [];
+    let v3IndexReads = 0;
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-v3-update-'));
+    try {
+      const service = new MarketplaceService({
+        rootDir: root,
+        pluginVersion: '3.0.0-beta.6',
+        registryClient: new MarketplaceRegistryClient({
+          pluginVersion: '3.0.0-beta.6',
+          fetch: async (input) => {
+            const url = String(input);
+            calls.push(url);
+            if (url === MARKETPLACE_REGISTRY_V3_INDEX_URL) {
+              v3IndexReads += 1;
+              return response(
+                createMarketplaceRegistryIndexV3(
+                  v3IndexReads === 1
+                    ? [createMarketplaceRegistryEntryV3(initial)]
+                    : [
+                        createMarketplaceRegistryEntryV3(initial),
+                        createMarketplaceRegistryEntryV3(updated),
+                      ],
+                ),
+              );
+            }
+            if (url === MARKETPLACE_REGISTRY_INDEX_URL) {
+              return response(indexFor(v2Replacement));
+            }
+            if (
+              url.endsWith(
+                '/v3/artifacts/community/registry-v3-agent/1.0.0.json',
+              )
+            ) {
+              return response(initial);
+            }
+            if (
+              url.endsWith(
+                '/v3/artifacts/community/registry-v3-agent/2.0.0.json',
+              )
+            ) {
+              return response(updated);
+            }
+            if (
+              url.endsWith(
+                '/v2/artifacts/community/registry-v3-agent/2.0.0.json',
+              )
+            ) {
+              return response(v2Replacement);
+            }
+            throw new Error(`Unexpected registry request: ${url}`);
+          },
+        }),
+      });
+
+      const installed = await service.installRemote(
+        'community/registry-v3-agent',
+      );
+      const result = await service.updateRemote('community/registry-v3-agent');
+
+      expect(installed.manifest.schemaVersion).toBe(3);
+      expect(result.manifest.schemaVersion).toBe(3);
+      expect(result.manifest.version).toBe('2.0.0');
+      expect(result.source).toEqual({
+        kind: 'registry',
+        registry: 'https://registry.ohmyopencodeslim.com/v3/',
+        indexUrl: MARKETPLACE_REGISTRY_V3_INDEX_URL,
+        packageUrl:
+          'https://registry.ohmyopencodeslim.com/v3/artifacts/community/registry-v3-agent/2.0.0.json',
+      });
+      expect(calls).toEqual([
+        MARKETPLACE_REGISTRY_V3_INDEX_URL,
+        'https://registry.ohmyopencodeslim.com/v3/artifacts/community/registry-v3-agent/1.0.0.json',
+        MARKETPLACE_REGISTRY_V3_INDEX_URL,
+        'https://registry.ohmyopencodeslim.com/v3/artifacts/community/registry-v3-agent/2.0.0.json',
+      ]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('falls back to v2 only when v3 is unavailable or lacks the package', async () => {
+    const v2Bundle = bundle();
+    const calls: string[] = [];
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-v3-fallback-'));
+    try {
+      const service = new MarketplaceService({
+        rootDir: root,
+        pluginVersion: '3.1.0',
+        registryClient: new MarketplaceRegistryClient({
+          pluginVersion: '3.1.0',
+          fetch: async (input) => {
+            const url = String(input);
+            calls.push(url);
+            if (url === MARKETPLACE_REGISTRY_V3_INDEX_URL) {
+              return response(createMarketplaceRegistryIndexV3([]));
+            }
+            if (url === MARKETPLACE_REGISTRY_INDEX_URL) {
+              return response(indexFor(v2Bundle));
+            }
+            return response(v2Bundle);
+          },
+        }),
+      });
+
+      const installed = await service.installRemote('community/registry-agent');
+      expect(installed.manifest.schemaVersion).toBe(2);
+      expect(calls).toEqual([
+        MARKETPLACE_REGISTRY_V3_INDEX_URL,
+        MARKETPLACE_REGISTRY_INDEX_URL,
+        'https://registry.ohmyopencodeslim.com/v2/artifacts/community/registry-agent/1.0.0.json',
+      ]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('falls back when the v3 endpoint is unavailable', async () => {
+    const packageBundle = bundle();
+    const calls: string[] = [];
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-v3-unavailable-'));
+    try {
+      const service = new MarketplaceService({
+        rootDir: root,
+        pluginVersion: '3.1.0',
+        registryClient: new MarketplaceRegistryClient({
+          pluginVersion: '3.1.0',
+          fetch: async (input) => {
+            const url = String(input);
+            calls.push(url);
+            if (url === MARKETPLACE_REGISTRY_V3_INDEX_URL) {
+              return response({}, 503);
+            }
+            if (url === MARKETPLACE_REGISTRY_INDEX_URL) {
+              return response(indexFor(packageBundle));
+            }
+            return response(packageBundle);
+          },
+        }),
+      });
+
+      expect(
+        (await service.installRemote('community/registry-agent')).manifest
+          .schemaVersion,
+      ).toBe(2);
+      expect(calls[0]).toBe(MARKETPLACE_REGISTRY_V3_INDEX_URL);
+      expect(calls[1]).toBe(MARKETPLACE_REGISTRY_INDEX_URL);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('does not fall back after malformed or integrity-invalid v3 data', async () => {
+    const packageBundle = bundleV3();
+    const malformedCalls: string[] = [];
+    const malformed = new MarketplaceRegistryClient({
+      pluginVersion: '3.0.0-beta.6',
+      fetch: async (input) => {
+        malformedCalls.push(String(input));
+        return response({ schemaVersion: 3, entries: [] });
+      },
+    });
+    await expect(
+      malformed.downloadV3('community/registry-v3-agent'),
+    ).rejects.toBeInstanceOf(MarketplaceRegistryProtocolError);
+    expect(malformedCalls).toEqual([MARKETPLACE_REGISTRY_V3_INDEX_URL]);
+
+    const integrityCalls: string[] = [];
+    const integrity = new MarketplaceRegistryClient({
+      pluginVersion: '3.0.0-beta.6',
+      fetch: async (input) => {
+        const url = String(input);
+        integrityCalls.push(url);
+        return url === MARKETPLACE_REGISTRY_V3_INDEX_URL
+          ? response(indexForV3(packageBundle))
+          : response({
+              ...packageBundle,
+              manifest: { ...packageBundle.manifest, description: 'forged' },
+            });
+      },
+    });
+    await expect(
+      integrity.downloadV3('community/registry-v3-agent'),
+    ).rejects.toBeInstanceOf(MarketplaceRegistryIntegrityError);
+    expect(integrityCalls).toHaveLength(2);
+    expect(integrityCalls[0]).toBe(MARKETPLACE_REGISTRY_V3_INDEX_URL);
+  });
+
   test('validates the index and artifact before store mutation and records provenance', async () => {
     const packageBundle = bundle();
     const calls: string[] = [];
@@ -259,7 +554,7 @@ describe('MarketplaceRegistryClient', () => {
       const service = new MarketplaceService({
         rootDir: root,
         pluginVersion: '3.1.0',
-        registryClient: client,
+        registryClient: { download: client.download.bind(client) },
       });
       const installed = await service.installRemote('community/registry-agent');
       expect(installed.manifest.version).toBe('1.0.0');

+ 108 - 20
src/marketplace/registry-client.ts

@@ -1,14 +1,22 @@
 import { satisfies } from 'semver';
 import {
   DEFAULT_MARKETPLACE_REGISTRY_URL,
+  DEFAULT_MARKETPLACE_REGISTRY_V3_URL,
   isMarketplaceRegistryIdRetired,
-  MarketplacePackageBundleSchema,
+  isMarketplaceRegistryIdRetiredV3,
+  MarketplacePackageBundleV2Schema,
+  MarketplacePackageBundleV3Schema,
   type MarketplaceRegistryEntry,
+  type MarketplaceRegistryEntryV3,
   type MarketplaceRegistryIndex,
+  type MarketplaceRegistryIndexV3,
   parseMarketplaceRegistryIndex,
+  parseMarketplaceRegistryIndexV3,
   parseMarketplaceRegistrySelector,
   resolveMarketplaceRegistryEntry,
+  resolveMarketplaceRegistryEntryV3,
   validateMarketplaceRegistryEntry,
+  validateMarketplaceRegistryEntryV3,
 } from '../marketplace-contract';
 import {
   MarketplaceCompatibilityError,
@@ -22,6 +30,8 @@ import { assertMarketplacePackageNotRetired } from './retirements';
 import type { MarketplacePackageBundle } from './schemas';
 
 export const MARKETPLACE_REGISTRY_INDEX_URL = `${DEFAULT_MARKETPLACE_REGISTRY_URL}index.json`;
+export const MARKETPLACE_REGISTRY_V2_INDEX_URL = MARKETPLACE_REGISTRY_INDEX_URL;
+export const MARKETPLACE_REGISTRY_V3_INDEX_URL = `${DEFAULT_MARKETPLACE_REGISTRY_V3_URL}index.json`;
 export const DEFAULT_MARKETPLACE_REGISTRY_TIMEOUT_MS = 10_000;
 export const DEFAULT_MARKETPLACE_REGISTRY_INDEX_MAX_BYTES = 2 * 1024 * 1024;
 export const DEFAULT_MARKETPLACE_REGISTRY_ARTIFACT_MAX_BYTES = 512 * 1024;
@@ -36,9 +46,10 @@ export interface MarketplaceRegistryClientOptions {
 
 export interface MarketplaceRegistryDownload {
   bundle: MarketplacePackageBundle;
-  entry: MarketplaceRegistryEntry;
+  entry: MarketplaceRegistryEntry | MarketplaceRegistryEntryV3;
   indexUrl: string;
   packageUrl: string;
+  registry?: string;
 }
 
 async function readBoundedBody(
@@ -125,6 +136,33 @@ export class MarketplaceRegistryClient {
     selectorText: string,
     minimumVersion?: string,
     signal?: AbortSignal,
+  ): Promise<MarketplaceRegistryDownload> {
+    return this.downloadFromRegistry(
+      selectorText,
+      minimumVersion,
+      signal,
+      false,
+    );
+  }
+
+  async downloadV3(
+    selectorText: string,
+    minimumVersion?: string,
+    signal?: AbortSignal,
+  ): Promise<MarketplaceRegistryDownload> {
+    return this.downloadFromRegistry(
+      selectorText,
+      minimumVersion,
+      signal,
+      true,
+    );
+  }
+
+  private async downloadFromRegistry(
+    selectorText: string,
+    minimumVersion: string | undefined,
+    signal: AbortSignal | undefined,
+    v3: boolean,
   ): Promise<MarketplaceRegistryDownload> {
     if (signal?.aborted) {
       throw new MarketplaceRegistryUnavailableError(
@@ -141,8 +179,31 @@ export class MarketplaceRegistryClient {
       }
     })();
     assertMarketplacePackageNotRetired(selector.id);
-    const index = await this.fetchIndex(signal);
-    if (isMarketplaceRegistryIdRetired(index, selector.id)) {
+    const indexText = await this.fetchJson(
+      v3 ? MARKETPLACE_REGISTRY_V3_INDEX_URL : MARKETPLACE_REGISTRY_INDEX_URL,
+      this.maxIndexBytes,
+      signal,
+    );
+    let index: MarketplaceRegistryIndex | MarketplaceRegistryIndexV3;
+    try {
+      index = v3
+        ? parseMarketplaceRegistryIndexV3(indexText)
+        : parseMarketplaceRegistryIndex(indexText);
+    } catch (error) {
+      throw new MarketplaceRegistryProtocolError(
+        error instanceof Error ? error.message : String(error),
+      );
+    }
+    const retired = v3
+      ? isMarketplaceRegistryIdRetiredV3(
+          index as MarketplaceRegistryIndexV3,
+          selector.id,
+        )
+      : isMarketplaceRegistryIdRetired(
+          index as MarketplaceRegistryIndex,
+          selector.id,
+        );
+    if (retired) {
       throw new MarketplaceRetiredError(
         `${selector.id} is retired and cannot be installed`,
       );
@@ -164,16 +225,25 @@ export class MarketplaceRegistryClient {
         `Marketplace package ${selector.id}@${selector.version} was not found in the registry`,
       );
     }
-    let entry: MarketplaceRegistryEntry;
+    let entry: MarketplaceRegistryEntry | MarketplaceRegistryEntryV3;
     try {
-      entry = resolveMarketplaceRegistryEntry(
-        index,
-        selector,
-        {
-          pluginVersion: this.options.pluginVersion,
-        },
-        minimumVersion,
-      );
+      entry = v3
+        ? resolveMarketplaceRegistryEntryV3(
+            index as MarketplaceRegistryIndexV3,
+            selector,
+            {
+              pluginVersion: this.options.pluginVersion,
+            },
+            minimumVersion,
+          )
+        : resolveMarketplaceRegistryEntry(
+            index as MarketplaceRegistryIndex,
+            selector,
+            {
+              pluginVersion: this.options.pluginVersion,
+            },
+            minimumVersion,
+          );
     } catch (error) {
       if (error instanceof MarketplaceRetiredError) throw error;
       throw new MarketplaceCompatibilityError(
@@ -183,7 +253,9 @@ export class MarketplaceRegistryClient {
 
     const packageUrl = new URL(
       entry.artifactPath,
-      DEFAULT_MARKETPLACE_REGISTRY_URL,
+      v3
+        ? DEFAULT_MARKETPLACE_REGISTRY_V3_URL
+        : DEFAULT_MARKETPLACE_REGISTRY_URL,
     ).href;
     const artifact = await this.fetchJson(
       packageUrl,
@@ -192,12 +264,23 @@ export class MarketplaceRegistryClient {
     );
     let bundle: MarketplacePackageBundle;
     try {
-      const result = MarketplacePackageBundleSchema.safeParse(artifact);
-      if (!result.success) {
-        throw new Error(result.error.message);
+      if (v3) {
+        const result = MarketplacePackageBundleV3Schema.safeParse(artifact);
+        if (!result.success) throw new Error(result.error.message);
+        bundle = result.data;
+        validateMarketplaceRegistryEntryV3(
+          entry as MarketplaceRegistryEntryV3,
+          bundle,
+        );
+      } else {
+        const result = MarketplacePackageBundleV2Schema.safeParse(artifact);
+        if (!result.success) throw new Error(result.error.message);
+        bundle = result.data;
+        validateMarketplaceRegistryEntry(
+          entry as MarketplaceRegistryEntry,
+          bundle,
+        );
       }
-      bundle = result.data;
-      validateMarketplaceRegistryEntry(entry, bundle);
     } catch (error) {
       throw new MarketplaceRegistryIntegrityError(
         error instanceof Error ? error.message : String(error),
@@ -216,8 +299,13 @@ export class MarketplaceRegistryClient {
     return {
       bundle,
       entry,
-      indexUrl: MARKETPLACE_REGISTRY_INDEX_URL,
+      indexUrl: v3
+        ? MARKETPLACE_REGISTRY_V3_INDEX_URL
+        : MARKETPLACE_REGISTRY_INDEX_URL,
       packageUrl,
+      registry: v3
+        ? DEFAULT_MARKETPLACE_REGISTRY_V3_URL
+        : DEFAULT_MARKETPLACE_REGISTRY_URL,
     };
   }
 

+ 66 - 1
src/marketplace/routing.test.ts

@@ -10,7 +10,10 @@ import {
 import { RuntimeConfig } from '../config/runtime';
 import { renderDefaultMarketplaceAutoDelegationBlock } from '../marketplace-contract';
 import { renderMarketplaceAutoDelegationBlock } from './routing';
-import type { MarketplacePackageManifest } from './schemas';
+import type {
+  MarketplacePackageManifest,
+  MarketplacePackageManifestV3,
+} from './schemas';
 import { MarketplaceStore } from './store';
 
 const baseManifest: MarketplacePackageManifest = {
@@ -36,6 +39,31 @@ const baseManifest: MarketplacePackageManifest = {
   model: { source: 'explicit', candidates: ['provider/model'] },
 };
 
+const v3Manifest: MarketplacePackageManifestV3 = {
+  schemaVersion: 3,
+  id: 'community/routing-v3-agent',
+  version: '1.0.0',
+  displayName: 'Routing v3 agent',
+  description: 'A v3 standalone routing agent.',
+  agentName: 'routing-v3-agent',
+  prompt: 'Package prompt.',
+  routing: {
+    lane: 'Deterministic package lane.',
+    stats: ['Fast implementation', 'Low context overhead'],
+    delegateWhen: ['The task has a bounded implementation scope.'],
+    avoid: ['Architecture decisions', 'Visual design work'],
+    additionalInstructions: ['Return a concise implementation summary.'],
+  },
+  skills: ['simplify'],
+  mcps: ['context7'],
+  tools: ['read', 'apply_patch'],
+  author: { name: 'Community' },
+  tags: ['routing'],
+  license: 'MIT',
+  compatibility: { plugin: '>=3.0.0-beta.3 <4.0.0' },
+  model: { source: 'explicit', candidates: ['provider/model'] },
+};
+
 function expectedSuffix(manifest: MarketplacePackageManifest): string {
   return [
     `- Package: ${manifest.displayName}`,
@@ -86,6 +114,43 @@ describe('marketplace routing renderer', () => {
     expect(renderMarketplaceAutoDelegationBlock(baseManifest)).toBe(expected);
   });
 
+  test('renders v3 standalone routing deterministically in source order', () => {
+    expect(renderMarketplaceAutoDelegationBlock(v3Manifest)).toBe(
+      [
+        '@routing-v3-agent',
+        '- Lane: Deterministic package lane.',
+        '- Role: A v3 standalone routing agent.',
+        '- Capabilities: Tools: read, apply_patch; Skills: simplify; MCPs: context7',
+        '- Stats: Fast implementation • Low context overhead',
+        '- **Delegate when:** The task has a bounded implementation scope.',
+        '- **Avoid:** Architecture decisions • Visual design work',
+        '- **Additional instructions:** Return a concise implementation summary.',
+      ].join('\n'),
+    );
+  });
+
+  test('renders v3 extensions after the current base-role block', () => {
+    const manifest: MarketplacePackageManifestV3 = {
+      ...v3Manifest,
+      extends: { builtin: 'fixer', promptMode: 'append' },
+    };
+    expect(renderMarketplaceAutoDelegationBlock(manifest, 'build-agent')).toBe(
+      [
+        ROLE_DEFINITIONS.fixer.routingBlock.replaceAll(
+          '@fixer',
+          '@build-agent',
+        ),
+        '',
+        '- Package: Routing v3 agent',
+        '- Package lane: Deterministic package lane.',
+        '- Stats: Fast implementation • Low context overhead',
+        '- **Delegate when:** The task has a bounded implementation scope.',
+        '- **Avoid:** Architecture decisions • Visual design work',
+        '- **Additional instructions:** Return a concise implementation summary.',
+      ].join('\n'),
+    );
+  });
+
   test('renders a runtime display alias without changing the manifest default', () => {
     const derived = {
       ...baseManifest,

+ 80 - 7
src/marketplace/routing.ts

@@ -2,11 +2,13 @@ import {
   ROLE_ROUTING_BLOCKS,
   renderRoleRoutingBlock,
 } from '../agents/role-routing';
-import type { MarketplacePackageManifest } from './schemas';
+import type {
+  MarketplacePackageManifest,
+  MarketplacePackageManifestV2,
+  MarketplacePackageManifestV3,
+} from './schemas';
 
-function renderMarketplacePackageSuffix(
-  manifest: MarketplacePackageManifest,
-): string {
+function renderV2PackageSuffix(manifest: MarketplacePackageManifestV2): string {
   return [
     `- Package: ${manifest.displayName}`,
     `- ${manifest.routing.description}`,
@@ -14,6 +16,50 @@ function renderMarketplacePackageSuffix(
   ].join('\n');
 }
 
+function renderRoutingList(values: readonly string[]): string {
+  return values.join(' • ');
+}
+
+function renderV3Capabilities(manifest: MarketplacePackageManifestV3): string {
+  const capabilities = [
+    manifest.tools.length > 0
+      ? `Tools: ${manifest.tools.join(', ')}`
+      : undefined,
+    manifest.skills.length > 0
+      ? `Skills: ${manifest.skills.join(', ')}`
+      : undefined,
+    manifest.mcps.length > 0 ? `MCPs: ${manifest.mcps.join(', ')}` : undefined,
+  ].filter((value): value is string => value !== undefined);
+  return capabilities.length > 0
+    ? `- Capabilities: ${capabilities.join('; ')}`
+    : '';
+}
+
+function renderV3RoutingDetails(
+  manifest: MarketplacePackageManifestV3,
+): string {
+  return [
+    `- Stats: ${renderRoutingList(manifest.routing.stats)}`,
+    `- **Delegate when:** ${renderRoutingList(manifest.routing.delegateWhen)}`,
+    `- **Avoid:** ${renderRoutingList(manifest.routing.avoid)}`,
+    ...(manifest.routing.additionalInstructions?.length
+      ? [
+          `- **Additional instructions:** ${renderRoutingList(manifest.routing.additionalInstructions)}`,
+        ]
+      : []),
+  ].join('\n');
+}
+
+function renderV3ExtensionSuffix(
+  manifest: MarketplacePackageManifestV3,
+): string {
+  return [
+    `- Package: ${manifest.displayName}`,
+    `- Package lane: ${manifest.routing.lane}`,
+    renderV3RoutingDetails(manifest),
+  ].join('\n');
+}
+
 /**
  * Render the default marketplace routing block for a runtime agent name.
  *
@@ -24,8 +70,25 @@ function renderMarketplacePackageSuffix(
 export function renderMarketplaceAutoDelegationBlock(
   manifest: MarketplacePackageManifest,
   runtimeName = manifest.agentName,
-  standaloneLaneDescription = manifest.description,
+  standaloneLaneDescription?: string,
 ): string {
+  if (manifest.schemaVersion === 2) {
+    const role = manifest.extends
+      ? {
+          id: manifest.extends.builtin,
+          routingBlock: ROLE_ROUTING_BLOCKS[manifest.extends.builtin],
+        }
+      : undefined;
+    const base = role
+      ? renderRoleRoutingBlock(role, runtimeName)
+      : [
+          `@${runtimeName}`,
+          `- Lane: ${standaloneLaneDescription ?? manifest.description}`,
+        ].join('\n');
+
+    return `${base}\n\n${renderV2PackageSuffix(manifest)}`;
+  }
+
   const role = manifest.extends
     ? {
         id: manifest.extends.builtin,
@@ -34,7 +97,17 @@ export function renderMarketplaceAutoDelegationBlock(
     : undefined;
   const base = role
     ? renderRoleRoutingBlock(role, runtimeName)
-    : [`@${runtimeName}`, `- Lane: ${standaloneLaneDescription}`].join('\n');
+    : [
+        `@${runtimeName}`,
+        `- Lane: ${standaloneLaneDescription ?? manifest.routing.lane}`,
+        `- Role: ${manifest.description}`,
+        renderV3Capabilities(manifest),
+      ]
+        .filter(Boolean)
+        .join('\n');
 
-  return `${base}\n\n${renderMarketplacePackageSuffix(manifest)}`;
+  if (!role) {
+    return `${base}\n${renderV3RoutingDetails(manifest)}`;
+  }
+  return `${base}\n\n${renderV3ExtensionSuffix(manifest)}`;
 }

+ 62 - 0
src/marketplace/schemas.test.ts

@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
 import {
   MarketplaceAgentManifestSchema,
   MarketplacePackageManifestSchema,
+  MarketplacePackageManifestV3Schema,
   MarketplaceVersionSchema,
 } from './schemas';
 
@@ -28,6 +29,18 @@ const common = {
   model: { source: 'explicit' as const, candidates: ['provider/model'] },
 };
 
+const commonV3 = {
+  ...common,
+  schemaVersion: 3 as const,
+  routing: {
+    lane: 'Bounded implementation work.',
+    stats: ['Fast execution', 'Low context overhead'],
+    delegateWhen: ['The task has a clear implementation boundary.'],
+    avoid: ['Architecture decisions'],
+    additionalInstructions: ['Report changed files and validation.'],
+  },
+};
+
 describe('agents-only marketplace manifest schemas', () => {
   test('rejects non-canonical semantic-version aliases', () => {
     expect(MarketplaceVersionSchema.safeParse('v1.0.0').success).toBe(false);
@@ -60,6 +73,55 @@ describe('agents-only marketplace manifest schemas', () => {
     ).toBe(false);
   });
 
+  test('accepts v3 routing and keeps the manifest union version-aware', () => {
+    expect(MarketplacePackageManifestV3Schema.safeParse(commonV3).success).toBe(
+      true,
+    );
+    expect(MarketplaceAgentManifestSchema.safeParse(commonV3).success).toBe(
+      true,
+    );
+    expect(
+      MarketplacePackageManifestSchema.safeParse({
+        ...common,
+        schemaVersion: 3,
+        routing: commonV3.routing,
+      }).success,
+    ).toBe(true);
+  });
+
+  test('rejects invalid v3 routing values and publisher-authored policy fields', () => {
+    const invalidCases = [
+      { routing: { ...commonV3.routing, lane: 'line\nwrapped' } },
+      { routing: { ...commonV3.routing, lane: 'x'.repeat(161) } },
+      {
+        routing: { ...commonV3.routing, stats: ['duplicate', 'duplicate'] },
+      },
+      { routing: { ...commonV3.routing, stats: [] } },
+      { routing: { ...commonV3.routing, delegateWhen: [] } },
+      { routing: { ...commonV3.routing, avoid: [] } },
+      {
+        routing: {
+          ...commonV3.routing,
+          additionalInstructions: Array.from({ length: 9 }, () => 'x'),
+        },
+      },
+      {
+        extends: { builtin: 'explorer', promptMode: 'replace' },
+      },
+      { permission: { read: 'allow' } },
+      { capabilities: ['read'] },
+    ];
+
+    for (const invalid of invalidCases) {
+      expect(
+        MarketplacePackageManifestV3Schema.safeParse({
+          ...commonV3,
+          ...invalid,
+        }).success,
+      ).toBe(false);
+    }
+  });
+
   test('rejects extension-only builtin model policy for standalone agents', () => {
     expect(
       MarketplaceAgentManifestSchema.safeParse({

+ 176 - 50
src/marketplace/schemas.ts

@@ -5,8 +5,11 @@ import { SUPPORTED_SPECIALIST_ROLES } from '../config/agent-roles';
 import { AGENT_THEME_COLORS } from '../config/constants';
 
 export const MARKETPLACE_MANIFEST_SCHEMA_VERSION = 2 as const;
+export const MARKETPLACE_MANIFEST_SCHEMA_VERSION_V3 = 3 as const;
 export const MARKETPLACE_LOCKFILE_SCHEMA_VERSION = 2 as const;
 export const MARKETPLACE_DIGEST_DOMAIN = 'marketplace-agent-bundle-v2' as const;
+export const MARKETPLACE_DIGEST_DOMAIN_V3 =
+  'marketplace-agent-bundle-v3' as const;
 
 const packageIdPattern =
   /^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,63}$/;
@@ -34,6 +37,15 @@ export const MarketplaceBuiltinSchema = z.enum(SUPPORTED_SPECIALIST_ROLES);
 export type MarketplaceBuiltin = z.infer<typeof MarketplaceBuiltinSchema>;
 
 const BoundedTextSchema = (max: number) => z.string().trim().min(1).max(max);
+const MarketplaceRoutingLineSchema = z
+  .string()
+  .refine((value) => !/[\r\n]/.test(value), {
+    message: 'Routing values must be single-line strings',
+  })
+  .trim()
+  .min(1)
+  .max(160);
+
 const UniqueStringArraySchema = z
   .array(z.string().trim().min(1).max(200))
   .max(128)
@@ -106,6 +118,23 @@ export const MarketplaceRoutingSchema = z
   })
   .strict();
 
+const UniqueRoutingLineArraySchema = z
+  .array(MarketplaceRoutingLineSchema)
+  .max(8)
+  .refine((values) => new Set(values).size === values.length, {
+    message: 'Routing values must be unique',
+  });
+
+export const MarketplaceRoutingV3Schema = z
+  .object({
+    lane: MarketplaceRoutingLineSchema,
+    stats: UniqueRoutingLineArraySchema.min(1),
+    delegateWhen: UniqueRoutingLineArraySchema.min(1),
+    avoid: UniqueRoutingLineArraySchema.min(1),
+    additionalInstructions: UniqueRoutingLineArraySchema.optional(),
+  })
+  .strict();
+
 export const MarketplaceExtensionSchema = z
   .object({
     builtin: MarketplaceBuiltinSchema,
@@ -113,69 +142,121 @@ export const MarketplaceExtensionSchema = z
   })
   .strict();
 
-const ManifestSchema = z
+export const MarketplaceExtensionV3Schema = z
+  .object({
+    builtin: MarketplaceBuiltinSchema,
+    promptMode: z.literal('append'),
+  })
+  .strict();
+
+const ManifestFields = {
+  id: MarketplacePackageIdSchema,
+  version: MarketplaceVersionSchema,
+  displayName: BoundedTextSchema(120),
+  description: BoundedTextSchema(1000),
+  agentName: z
+    .string()
+    .trim()
+    .regex(/^[a-z][a-z0-9_-]{0,63}$/, 'Expected a valid agent name'),
+  prompt: BoundedTextSchema(100_000),
+  skills: UniqueStringArraySchema,
+  mcps: UniqueStringArraySchema,
+  tools: z
+    .array(MarketplaceToolSchema)
+    .max(11)
+    .refine((values) => new Set(values).size === values.length, {
+      message: 'Tools must be unique',
+    }),
+  author: MarketplaceAuthorSchema,
+  tags: UniqueStringArraySchema,
+  license: BoundedTextSchema(64),
+  compatibility: MarketplaceCompatibilitySchema,
+  model: MarketplaceModelPolicySchema,
+  temperature: z.number().min(0).max(2).optional(),
+  color: z
+    .union([z.string().regex(/^#[0-9a-fA-F]{6}$/), z.enum(AGENT_THEME_COLORS)])
+    .optional(),
+} as const;
+
+const ManifestV2Schema = z
   .object({
     schemaVersion: z.literal(MARKETPLACE_MANIFEST_SCHEMA_VERSION),
-    id: MarketplacePackageIdSchema,
-    version: MarketplaceVersionSchema,
-    displayName: BoundedTextSchema(120),
-    description: BoundedTextSchema(1000),
-    agentName: z
-      .string()
-      .trim()
-      .regex(/^[a-z][a-z0-9_-]{0,63}$/, 'Expected a valid agent name'),
-    prompt: BoundedTextSchema(100_000),
+    ...ManifestFields,
     routing: MarketplaceRoutingSchema,
-    skills: UniqueStringArraySchema,
-    mcps: UniqueStringArraySchema,
-    tools: z
-      .array(MarketplaceToolSchema)
-      .max(11)
-      .refine((values) => new Set(values).size === values.length, {
-        message: 'Tools must be unique',
-      }),
-    author: MarketplaceAuthorSchema,
-    tags: UniqueStringArraySchema,
-    license: BoundedTextSchema(64),
-    compatibility: MarketplaceCompatibilitySchema,
-    model: MarketplaceModelPolicySchema,
-    temperature: z.number().min(0).max(2).optional(),
-    color: z
-      .union([
-        z.string().regex(/^#[0-9a-fA-F]{6}$/),
-        z.enum(AGENT_THEME_COLORS),
-      ])
-      .optional(),
-  })
-  .strict();
-
-export const MarketplacePackageManifestSchema = ManifestSchema.extend({
+  })
+  .strict();
+
+const ManifestV3Schema = z
+  .object({
+    schemaVersion: z.literal(MARKETPLACE_MANIFEST_SCHEMA_VERSION_V3),
+    ...ManifestFields,
+    routing: MarketplaceRoutingV3Schema,
+  })
+  .strict();
+
+function validateBuiltinModelPolicy(
+  manifest: { model: MarketplaceModelPolicy; extends?: unknown },
+  ctx: z.RefinementCtx,
+): void {
+  if (manifest.model.source === 'builtin' && !manifest.extends) {
+    ctx.addIssue({
+      code: 'custom',
+      path: ['model', 'source'],
+      message: "model.source 'builtin' requires extends",
+    });
+  }
+}
+
+export const MarketplacePackageManifestV2Schema = ManifestV2Schema.extend({
   extends: MarketplaceExtensionSchema.optional(),
 })
   .strict()
-  .superRefine((manifest, ctx) => {
-    if (manifest.model.source === 'builtin' && !manifest.extends) {
-      ctx.addIssue({
-        code: 'custom',
-        path: ['model', 'source'],
-        message: "model.source 'builtin' requires extends",
-      });
-    }
-  });
+  .superRefine(validateBuiltinModelPolicy);
+
+export const MarketplacePackageManifestV3Schema = ManifestV3Schema.extend({
+  extends: MarketplaceExtensionV3Schema.optional(),
+})
+  .strict()
+  .superRefine(validateBuiltinModelPolicy);
 
-export const MarketplaceAgentManifestSummarySchema = ManifestSchema.omit({
+export const MarketplacePackageManifestSchema = z.discriminatedUnion(
+  'schemaVersion',
+  [MarketplacePackageManifestV2Schema, MarketplacePackageManifestV3Schema],
+);
+
+export const MarketplaceAgentManifestSummaryV2Schema = ManifestV2Schema.omit({
   prompt: true,
 })
   .extend({ extends: MarketplaceExtensionSchema.optional() })
   .strict();
 
+export const MarketplaceAgentManifestSummaryV3Schema = ManifestV3Schema.omit({
+  prompt: true,
+})
+  .extend({ extends: MarketplaceExtensionV3Schema.optional() })
+  .strict();
+
+export const MarketplaceAgentManifestSummarySchema = z.discriminatedUnion(
+  'schemaVersion',
+  [
+    MarketplaceAgentManifestSummaryV2Schema,
+    MarketplaceAgentManifestSummaryV3Schema,
+  ],
+);
+
 // Kept as the precise agent-manifest name for consumers of the public contract.
 export const MarketplaceAgentManifestSchema = MarketplacePackageManifestSchema;
 
 export const MarketplacePackageBundleSchema = z
-  .object({
-    manifest: MarketplacePackageManifestSchema,
-  })
+  .object({ manifest: MarketplacePackageManifestSchema })
+  .strict();
+
+export const MarketplacePackageBundleV2Schema = z
+  .object({ manifest: MarketplacePackageManifestV2Schema })
+  .strict();
+
+export const MarketplacePackageBundleV3Schema = z
+  .object({ manifest: MarketplacePackageManifestV3Schema })
   .strict();
 
 export const MarketplaceLocalSourceSchema = z
@@ -219,15 +300,45 @@ export const MarketplaceDigestSchema = z
   })
   .strict();
 
-export const MarketplaceLockEntrySchema = z
+export const MarketplaceDigestV3Schema = z
+  .object({
+    algorithm: z.literal('sha256'),
+    domain: z.literal(MARKETPLACE_DIGEST_DOMAIN_V3),
+    value: z.string().regex(/^[0-9a-f]{64}$/),
+  })
+  .strict();
+
+export const MarketplaceDigestUnionSchema = z.union([
+  MarketplaceDigestSchema,
+  MarketplaceDigestV3Schema,
+]);
+
+const MarketplaceLockEntryFields = {
+  manifestVersion: MarketplaceVersionSchema,
+  source: MarketplaceSourceSchema,
+} as const;
+
+export const MarketplaceLockEntryV2Schema = z
   .object({
     manifestSchemaVersion: z.literal(MARKETPLACE_MANIFEST_SCHEMA_VERSION),
-    manifestVersion: MarketplaceVersionSchema,
-    source: MarketplaceSourceSchema,
+    ...MarketplaceLockEntryFields,
     digest: MarketplaceDigestSchema,
   })
   .strict();
 
+export const MarketplaceLockEntryV3Schema = z
+  .object({
+    manifestSchemaVersion: z.literal(MARKETPLACE_MANIFEST_SCHEMA_VERSION_V3),
+    ...MarketplaceLockEntryFields,
+    digest: MarketplaceDigestV3Schema,
+  })
+  .strict();
+
+export const MarketplaceLockEntrySchema = z.discriminatedUnion(
+  'manifestSchemaVersion',
+  [MarketplaceLockEntryV2Schema, MarketplaceLockEntryV3Schema],
+);
+
 export const MarketplaceLockfileSchema = z
   .object({
     schemaVersion: z.literal(MARKETPLACE_LOCKFILE_SCHEMA_VERSION),
@@ -243,13 +354,28 @@ export type MarketplaceModelPolicy = z.infer<
 export type MarketplaceAgentManifest = z.infer<
   typeof MarketplaceAgentManifestSchema
 >;
+export type MarketplaceAgentManifestV2 = z.infer<
+  typeof MarketplacePackageManifestV2Schema
+>;
+export type MarketplaceAgentManifestV3 = z.infer<
+  typeof MarketplacePackageManifestV3Schema
+>;
 export type MarketplacePackageManifest = z.infer<
   typeof MarketplacePackageManifestSchema
 >;
+export type MarketplacePackageManifestV2 = MarketplaceAgentManifestV2;
+export type MarketplacePackageManifestV3 = MarketplaceAgentManifestV3;
 export type MarketplacePackageBundle = z.infer<
   typeof MarketplacePackageBundleSchema
 >;
 export type MarketplaceSource = z.infer<typeof MarketplaceSourceSchema>;
 export type MarketplaceDigest = z.infer<typeof MarketplaceDigestSchema>;
+export type MarketplaceDigestV3 = z.infer<typeof MarketplaceDigestV3Schema>;
+export type MarketplaceLockEntryV2 = z.infer<
+  typeof MarketplaceLockEntryV2Schema
+>;
+export type MarketplaceLockEntryV3 = z.infer<
+  typeof MarketplaceLockEntryV3Schema
+>;
 export type MarketplaceLockEntry = z.infer<typeof MarketplaceLockEntrySchema>;
 export type MarketplaceLockfile = z.infer<typeof MarketplaceLockfileSchema>;

+ 38 - 6
src/marketplace/service.ts

@@ -9,10 +9,12 @@ import {
 import {
   MarketplaceActivationReferenceError,
   MarketplaceConflictError,
+  MarketplaceRegistryNotFoundError,
   MarketplaceRegistryUnavailableError,
   MarketplaceValidationError,
 } from './errors';
 import { normalizeMarketplacePackageId } from './ids';
+import type { MarketplaceRegistryDownload } from './registry-client';
 import { MarketplaceRegistryClient } from './registry-client';
 import { assertMarketplacePackageNotRetired } from './retirements';
 import {
@@ -32,9 +34,15 @@ export interface MarketplaceServiceOptions
   extends MarketplaceStoreOptions,
     MarketplaceCompatibilityOptions {
   projectDir?: string;
-  registryClient?: Pick<MarketplaceRegistryClient, 'download'>;
+  registryClient?: MarketplaceRegistryDownloadClient;
 }
 
+export type MarketplaceRegistryDownloadClient = Pick<
+  MarketplaceRegistryClient,
+  'download'
+> &
+  Partial<Pick<MarketplaceRegistryClient, 'downloadV3'>>;
+
 export interface MarketplaceRemoveOptions {
   force?: boolean;
 }
@@ -73,7 +81,7 @@ function referenceMessage(
 export class MarketplaceService {
   readonly store: MarketplaceStore;
   readonly projectDir: string;
-  readonly registryClient: Pick<MarketplaceRegistryClient, 'download'>;
+  readonly registryClient: MarketplaceRegistryDownloadClient;
 
   constructor(options: MarketplaceServiceOptions = {}) {
     this.store = new MarketplaceStore(options);
@@ -132,7 +140,7 @@ export class MarketplaceService {
   ): Promise<StoredMarketplacePackage> {
     const selectorId = selector.trim().split('@', 1)[0].toLowerCase();
     assertMarketplacePackageNotRetired(selectorId);
-    const downloaded = await this.registryClient.download(
+    const downloaded = await this.downloadRemoteWithV3Fallback(
       selector,
       undefined,
       signal,
@@ -144,12 +152,36 @@ export class MarketplaceService {
     }
     return this.store.install(downloaded.bundle, {
       kind: 'registry',
-      registry: DEFAULT_MARKETPLACE_REGISTRY_URL,
+      registry: downloaded.registry ?? DEFAULT_MARKETPLACE_REGISTRY_URL,
       indexUrl: downloaded.indexUrl,
       packageUrl: downloaded.packageUrl,
     });
   }
 
+  private async downloadRemoteWithV3Fallback(
+    selector: string,
+    minimumVersion?: string,
+    signal?: AbortSignal,
+  ): Promise<MarketplaceRegistryDownload> {
+    if (this.registryClient.downloadV3) {
+      try {
+        return await this.registryClient.downloadV3(
+          selector,
+          minimumVersion,
+          signal,
+        );
+      } catch (error) {
+        if (
+          !(error instanceof MarketplaceRegistryUnavailableError) &&
+          !(error instanceof MarketplaceRegistryNotFoundError)
+        ) {
+          throw error;
+        }
+      }
+    }
+    return this.registryClient.download(selector, minimumVersion, signal);
+  }
+
   async updateRemote(
     id: string,
     signal?: AbortSignal,
@@ -162,7 +194,7 @@ export class MarketplaceService {
       );
     }
     const current = this.store.show(normalizedId);
-    const downloaded = await this.registryClient.download(
+    const downloaded = await this.downloadRemoteWithV3Fallback(
       current.manifest.id,
       current.manifest.version,
       signal,
@@ -174,7 +206,7 @@ export class MarketplaceService {
     }
     return this.store.update(downloaded.bundle, {
       kind: 'registry',
-      registry: DEFAULT_MARKETPLACE_REGISTRY_URL,
+      registry: downloaded.registry ?? DEFAULT_MARKETPLACE_REGISTRY_URL,
       indexUrl: downloaded.indexUrl,
       packageUrl: downloaded.packageUrl,
     });

+ 18 - 2
src/marketplace/store.ts

@@ -29,6 +29,7 @@ import { getMarketplacePaths, type MarketplacePaths } from './paths';
 import { assertMarketplacePackageNotRetired } from './retirements';
 import {
   MARKETPLACE_DIGEST_DOMAIN,
+  MARKETPLACE_DIGEST_DOMAIN_V3,
   MARKETPLACE_LOCKFILE_SCHEMA_VERSION,
   type MarketplaceLockEntry,
   type MarketplaceLockfile,
@@ -112,8 +113,20 @@ function packageEntry(
   source: MarketplaceSource,
   digest: string,
 ): MarketplaceLockEntry {
+  if (bundle.manifest.schemaVersion === 3) {
+    return {
+      manifestSchemaVersion: 3,
+      manifestVersion: bundle.manifest.version,
+      source,
+      digest: {
+        algorithm: 'sha256',
+        domain: MARKETPLACE_DIGEST_DOMAIN_V3,
+        value: digest,
+      },
+    };
+  }
   return {
-    manifestSchemaVersion: bundle.manifest.schemaVersion,
+    manifestSchemaVersion: 2,
     manifestVersion: bundle.manifest.version,
     source,
     digest: {
@@ -586,7 +599,10 @@ export class MarketplaceStore {
         bundle.manifest.schemaVersion !== entry.manifestSchemaVersion ||
         sidecar !== digest ||
         entry.digest.algorithm !== 'sha256' ||
-        entry.digest.domain !== MARKETPLACE_DIGEST_DOMAIN ||
+        entry.digest.domain !==
+          (bundle.manifest.schemaVersion === 3
+            ? MARKETPLACE_DIGEST_DOMAIN_V3
+            : MARKETPLACE_DIGEST_DOMAIN) ||
         entry.digest.value !== digest
       ) {
         throw new MarketplaceIntegrityError(