Explorar el Código

feat(marketplace): retire deepwork packages

Alvin Unreal hace 5 días
padre
commit
716dcb54d0

+ 5 - 4
docs/marketplace.md

@@ -72,7 +72,7 @@ read (for example EACCES).
 | installed | Exact locked versions in the local store |
 | configured_agents / configured_profiles | Active-preset activation on disk |
 | live_packages | Packages already in this session's registry, with version, digest, and runtime name |
-| diagnostics | Store and activation issues (missing, corrupt, operational, collision, missing required dependency, invalid alias), labeled `disk` or `live` |
+| diagnostics | Store and activation issues (missing, corrupt, operational, collision, missing required dependency, invalid alias, retired), labeled `disk` or `live` |
 | reload_required | `true`/`false` when live and desired identities can be compared; `unknown` for CLI and when store/desired resolution failed operationally |
 
 ## Limits
@@ -90,6 +90,7 @@ read (for example EACCES).
 
 Registry CI and static site tooling can import the narrow
 `oh-my-opencode-slim/marketplace-contract` package subpath. It provides the
-schema-v1 index, deterministic artifact paths, manifest-summary projection,
-selector resolution, and the same canonical bundle SHA-256 digest used by the
-plugin store. The public contract does not add root-package exports.
+schema-v1 and schema-v2 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. 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.1",
+  "version": "3.0.0-beta.2",
   "packageManager": "bun@1.3.14",
   "description": "Lightweight agent orchestration plugin for OpenCode - a slimmed-down fork of oh-my-opencode",
   "main": "dist/index.js",

+ 132 - 35
src/marketplace-contract/index.ts

@@ -6,6 +6,8 @@ import {
   compareMarketplaceCodeUnits,
   digestMarketplaceBundle,
 } from '../marketplace/canonical';
+import { MarketplaceRetiredError } from '../marketplace/errors';
+import { isMarketplacePackageRetired } from '../marketplace/retirements';
 import {
   MARKETPLACE_DIGEST_DOMAIN,
   MARKETPLACE_MANIFEST_SCHEMA_VERSION,
@@ -46,7 +48,7 @@ export {
   MarketplaceVersionSchema,
 };
 
-export const MARKETPLACE_REGISTRY_SCHEMA_VERSION = 1 as const;
+export const MARKETPLACE_REGISTRY_SCHEMA_VERSION = 2 as const;
 export const DEFAULT_MARKETPLACE_REGISTRY_URL =
   'https://registry.ohmyopencodeslim.com/v1/' as const;
 
@@ -84,69 +86,126 @@ export const MarketplaceRegistryEntrySchema = z
   })
   .strict();
 
-export const MarketplaceRegistryIndexSchema = z
+export const MarketplaceRegistryRetirementSchema = z
+  .object({ id: MarketplacePackageIdSchema })
+  .strict();
+
+function validateRegistryEntries(
+  entries: readonly MarketplaceRegistryEntry[],
+  ctx: z.RefinementCtx,
+): void {
+  const seen = new Set<string>();
+  for (let position = 0; position < entries.length; position += 1) {
+    const entry = entries[position];
+    const key = `${entry.id}@${entry.version}`;
+    if (seen.has(key)) {
+      ctx.addIssue({
+        code: 'custom',
+        path: ['entries', position],
+        message: `Duplicate registry entry ${key}`,
+      });
+    }
+    seen.add(key);
+    if (entry.artifactPath !== registryArtifactPath(entry.id, entry.version)) {
+      ctx.addIssue({
+        code: 'custom',
+        path: ['entries', position, 'artifactPath'],
+        message: `Artifact path must be ${registryArtifactPath(entry.id, entry.version)}`,
+      });
+    }
+    if (
+      entry.summary.id !== entry.id ||
+      entry.summary.version !== entry.version
+    ) {
+      ctx.addIssue({
+        code: 'custom',
+        path: ['entries', position, 'summary'],
+        message: 'Summary identity must match the registry entry',
+      });
+    }
+    const previous = entries[position - 1];
+    if (
+      previous &&
+      (compareMarketplaceCodeUnits(previous.id, entry.id) > 0 ||
+        (previous.id === entry.id &&
+          (compare(previous.version, entry.version) > 0 ||
+            (compare(previous.version, entry.version) === 0 &&
+              compareMarketplaceCodeUnits(previous.version, entry.version) >=
+                0))))
+    ) {
+      ctx.addIssue({
+        code: 'custom',
+        path: ['entries', position],
+        message: 'Registry entries must be sorted by ID then version',
+      });
+    }
+  }
+}
+
+const MarketplaceRegistryIndexV1Schema = z
+  .object({
+    schemaVersion: z.literal(1),
+    entries: z.array(MarketplaceRegistryEntrySchema).max(100_000),
+  })
+  .strict()
+  .superRefine((index, ctx) => validateRegistryEntries(index.entries, ctx));
+
+const MarketplaceRegistryIndexV2Schema = z
   .object({
     schemaVersion: z.literal(MARKETPLACE_REGISTRY_SCHEMA_VERSION),
     entries: z.array(MarketplaceRegistryEntrySchema).max(100_000),
+    retirements: z.array(MarketplaceRegistryRetirementSchema).max(100_000),
   })
   .strict()
   .superRefine((index, ctx) => {
+    validateRegistryEntries(index.entries, ctx);
+    const entryIds = new Set(index.entries.map((entry) => entry.id));
     const seen = new Set<string>();
-    for (let position = 0; position < index.entries.length; position += 1) {
-      const entry = index.entries[position];
-      const key = `${entry.id}@${entry.version}`;
-      if (seen.has(key)) {
-        ctx.addIssue({
-          code: 'custom',
-          path: ['entries', position],
-          message: `Duplicate registry entry ${key}`,
-        });
-      }
-      seen.add(key);
-      if (
-        entry.artifactPath !== registryArtifactPath(entry.id, entry.version)
-      ) {
+    for (let position = 0; position < index.retirements.length; position += 1) {
+      const retirement = index.retirements[position];
+      if (seen.has(retirement.id)) {
         ctx.addIssue({
           code: 'custom',
-          path: ['entries', position, 'artifactPath'],
-          message: `Artifact path must be ${registryArtifactPath(entry.id, entry.version)}`,
+          path: ['retirements', position],
+          message: `Duplicate marketplace retirement ${retirement.id}`,
         });
       }
-      if (
-        entry.summary.id !== entry.id ||
-        entry.summary.version !== entry.version
-      ) {
+      seen.add(retirement.id);
+      if (!entryIds.has(retirement.id)) {
         ctx.addIssue({
           code: 'custom',
-          path: ['entries', position, 'summary'],
-          message: 'Summary identity must match the registry entry',
+          path: ['retirements', position, 'id'],
+          message: `Marketplace retirement ${retirement.id} has no registry entry`,
         });
       }
-      const previous = index.entries[position - 1];
+      const previous = index.retirements[position - 1];
       if (
         previous &&
-        (compareMarketplaceCodeUnits(previous.id, entry.id) > 0 ||
-          (previous.id === entry.id &&
-            (compare(previous.version, entry.version) > 0 ||
-              (compare(previous.version, entry.version) === 0 &&
-                compareMarketplaceCodeUnits(previous.version, entry.version) >=
-                  0))))
+        compareMarketplaceCodeUnits(previous.id, retirement.id) >= 0
       ) {
         ctx.addIssue({
           code: 'custom',
-          path: ['entries', position],
-          message: 'Registry entries must be sorted by ID then version',
+          path: ['retirements', position],
+          message: 'Marketplace retirements must be sorted by ID',
         });
       }
     }
   });
 
+export const MarketplaceRegistryIndexSchema = z.discriminatedUnion(
+  'schemaVersion',
+  [MarketplaceRegistryIndexV1Schema, MarketplaceRegistryIndexV2Schema],
+);
+
 export type MarketplaceManifestSummary = z.infer<
   typeof MarketplaceManifestSummarySchema
 >;
 export type MarketplaceRegistryEntry = z.infer<
   typeof MarketplaceRegistryEntrySchema
 >;
+export type MarketplaceRegistryRetirement = z.infer<
+  typeof MarketplaceRegistryRetirementSchema
+>;
 export type MarketplaceRegistryIndex = z.infer<
   typeof MarketplaceRegistryIndexSchema
 >;
@@ -192,6 +251,7 @@ export function createMarketplaceRegistryEntry(
 
 export function createMarketplaceRegistryIndex(
   entries: readonly MarketplaceRegistryEntry[],
+  retirements: readonly MarketplaceRegistryRetirement[] = [],
 ): MarketplaceRegistryIndex {
   const sorted = [...entries].sort(
     (left, right) =>
@@ -199,7 +259,36 @@ export function createMarketplaceRegistryIndex(
       compare(left.version, right.version) ||
       compareMarketplaceCodeUnits(left.version, right.version),
   );
-  return parseMarketplaceRegistryIndex({ schemaVersion: 1, entries: sorted });
+  const sortedRetirements = [...retirements].sort((left, right) =>
+    compareMarketplaceCodeUnits(left.id, right.id),
+  );
+  return parseMarketplaceRegistryIndex({
+    schemaVersion: MARKETPLACE_REGISTRY_SCHEMA_VERSION,
+    entries: sorted,
+    retirements: sortedRetirements,
+  });
+}
+
+export function retiredMarketplaceRegistryIds(
+  index: MarketplaceRegistryIndex,
+): ReadonlySet<string> {
+  return new Set(
+    index.schemaVersion === 2 ? index.retirements.map(({ id }) => id) : [],
+  );
+}
+
+export function isMarketplaceRegistryIdRetired(
+  index: MarketplaceRegistryIndex,
+  id: string,
+): boolean {
+  return retiredMarketplaceRegistryIds(index).has(id);
+}
+
+export function filterMarketplaceRegistryEntries(
+  index: MarketplaceRegistryIndex,
+): MarketplaceRegistryEntry[] {
+  const retired = retiredMarketplaceRegistryIds(index);
+  return index.entries.filter((entry) => !retired.has(entry.id));
 }
 
 export function parseMarketplaceRegistryIndex(
@@ -277,7 +366,15 @@ export function resolveMarketplaceRegistryEntry(
   compatibility: { pluginVersion: string; roleContractVersion: string },
   minimumVersion?: string,
 ): MarketplaceRegistryEntry {
-  const candidates = index.entries.filter(
+  if (
+    isMarketplacePackageRetired(selector.id) ||
+    isMarketplaceRegistryIdRetired(index, selector.id)
+  ) {
+    throw new MarketplaceRetiredError(
+      `${selector.id}${selector.version ? `@${selector.version}` : ''} is retired and cannot be installed`,
+    );
+  }
+  const candidates = filterMarketplaceRegistryEntries(index).filter(
     (entry) =>
       entry.id === selector.id &&
       (selector.version === undefined || entry.version === selector.version) &&

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

@@ -16,6 +16,7 @@ import type {
 } from '../config/schema';
 import { MarketplaceActivationError } from './errors';
 import { normalizeMarketplacePackageId } from './ids';
+import { assertMarketplacePackageNotRetired } from './retirements';
 import { MarketplaceStore } from './store';
 
 function writeConfigFile(
@@ -142,6 +143,7 @@ export function enableMarketplaceAgent(
   store = new MarketplaceStore(),
 ): void {
   const id = normalizeMarketplacePackageId(packageId);
+  assertMarketplacePackageNotRetired(id);
   const pkg = store.show(id);
   if (pkg.manifest.kind !== 'agent') {
     throw new MarketplaceActivationError(
@@ -205,6 +207,7 @@ export function setMarketplaceProfile(
     return;
   }
   const id = normalizeMarketplacePackageId(packageId);
+  assertMarketplacePackageNotRetired(id);
   const pkg = store.show(id);
   if (pkg.manifest.kind !== 'profile') {
     throw new MarketplaceActivationError(

+ 35 - 0
src/marketplace/activation.test.ts

@@ -499,6 +499,41 @@ describe('marketplace runtime activation', () => {
     }
   });
 
+  test('reports manually persisted retired activation without creating an agent', () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      const registry = registryFor(
+        {
+          preset: 'work',
+          presets: {
+            work: {
+              agents: {},
+              marketplace: {
+                agents: ['alvin/deepwork-implementer'],
+              },
+            },
+          },
+        },
+        store,
+        'marketplace-retired-test',
+      );
+      expect(
+        registry.agents.some((agent) => agent.name === 'implementer'),
+      ).toBe(false);
+      expect(registry.diagnostics).toEqual([
+        {
+          packageId: 'alvin/deepwork-implementer',
+          code: 'retired',
+          message:
+            'alvin/deepwork-implementer is retired and will not be activated',
+        },
+      ]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
   test('does not contact the network while resolving activation', () => {
     const root = mkdtempSync(join(tmpdir(), 'marketplace-activation-'));
     const originalFetch = globalThis.fetch;

+ 17 - 1
src/marketplace/activation.ts

@@ -18,6 +18,7 @@ import {
   discoverPreflightMcps,
   discoverPreflightSkills,
 } from './preflight';
+import { isMarketplacePackageRetired } from './retirements';
 import type {
   MarketplaceAgentManifest,
   MarketplacePackageManifest,
@@ -42,7 +43,8 @@ export type MarketplaceDiagnosticCode =
   | 'target-mismatch'
   | 'target-disabled'
   | 'prompt-masked'
-  | 'invalid-alias';
+  | 'invalid-alias'
+  | 'retired';
 
 export interface MarketplaceDiagnostic {
   packageId: string;
@@ -276,6 +278,7 @@ export function resolveMarketplaceActivation(
   let selectedPackages = new Map<string, StoredMarketplacePackage>();
   let selectedErrors = new Map<string, Error>();
   let selectedLoadFailed = false;
+  const reportedRetired = new Set<string>();
   try {
     const selected = store.loadSelected(selectedIds);
     selectedPackages = selected.packages;
@@ -299,6 +302,19 @@ export function resolveMarketplaceActivation(
     } catch {
       return undefined;
     }
+    if (isMarketplacePackageRetired(normalized)) {
+      if (!reportedRetired.has(normalized)) {
+        reportedRetired.add(normalized);
+        diagnostics.push(
+          diagnostic(
+            normalized,
+            'retired',
+            `${normalized} is retired and will not be activated`,
+          ),
+        );
+      }
+      return undefined;
+    }
     const error = selectedErrors.get(normalized);
     if (error) {
       diagnostics.push(

+ 7 - 0
src/marketplace/errors.ts

@@ -57,6 +57,13 @@ export class MarketplaceCompatibilityError extends MarketplaceError {
   }
 }
 
+export class MarketplaceRetiredError extends MarketplaceError {
+  constructor(message: string) {
+    super(message, 'retired');
+    this.name = 'MarketplaceRetiredError';
+  }
+}
+
 export class MarketplaceActivationReferenceError extends MarketplaceError {
   constructor(message: string) {
     super(message, 'activation-reference');

+ 1 - 0
src/marketplace/index.ts

@@ -7,6 +7,7 @@ export * from './errors';
 export * from './ids';
 export * from './paths';
 export * from './registry-client';
+export * from './retirements';
 export * from './schemas';
 export * from './service';
 export * from './status';

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

@@ -1,5 +1,5 @@
 import { describe, expect, test } from 'bun:test';
-import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
+import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import { join } from 'node:path';
 import type { MarketplacePackageBundle } from '../marketplace-contract';
@@ -21,6 +21,7 @@ import {
   MarketplaceRegistryNotFoundError,
   MarketplaceRegistryProtocolError,
   MarketplaceRegistryUnavailableError,
+  MarketplaceRetiredError,
 } from './errors';
 import {
   DEFAULT_MARKETPLACE_REGISTRY_ARTIFACT_MAX_BYTES,
@@ -170,6 +171,60 @@ describe('marketplace registry contract', () => {
     ).toThrow();
   });
 
+  test('validates v2 retirement tombstones and rejects retired selectors', () => {
+    const first = indexFor(bundle('1.0.0', 'community/alpha'));
+    const second = indexFor(bundle('1.0.0', 'community/beta'));
+    const entries = [
+      ...(first.entries as unknown[]),
+      ...(second.entries as unknown[]),
+    ];
+    expect(
+      parseMarketplaceRegistryIndex({
+        schemaVersion: 2,
+        entries,
+        retirements: [{ id: 'community/alpha' }],
+      }).schemaVersion,
+    ).toBe(2);
+    expect(() =>
+      parseMarketplaceRegistryIndex({
+        schemaVersion: 2,
+        entries,
+        retirements: [{ id: 'community/missing' }],
+      }),
+    ).toThrow('no registry entry');
+    expect(() =>
+      parseMarketplaceRegistryIndex({
+        schemaVersion: 2,
+        entries,
+        retirements: [{ id: 'community/alpha' }, { id: 'community/alpha' }],
+      }),
+    ).toThrow('Duplicate');
+    expect(() =>
+      parseMarketplaceRegistryIndex({
+        schemaVersion: 2,
+        entries,
+        retirements: [{ id: 'community/beta' }, { id: 'community/alpha' }],
+      }),
+    ).toThrow('sorted');
+
+    const index = parseMarketplaceRegistryIndex({
+      schemaVersion: 2,
+      entries,
+      retirements: [{ id: 'community/alpha' }],
+    });
+    for (const selector of [
+      { id: 'community/alpha' },
+      { id: 'community/alpha', version: '1.0.0' },
+    ]) {
+      expect(() =>
+        resolveMarketplaceRegistryEntry(index, selector, {
+          pluginVersion: '3.1.0',
+          roleContractVersion: '1.0.0',
+        }),
+      ).toThrow(MarketplaceRetiredError);
+    }
+  });
+
   test('uses locale-independent code-unit ordering for JSON and catalog entries', () => {
     expect(canonicalizeMarketplaceValue({ a_: 1, 'a-': 2 })).toBe(
       '{"a-":2,"a_":1}',
@@ -224,6 +279,45 @@ describe('MarketplaceRegistryClient', () => {
     }
   });
 
+  test('rejects policy-retired remote selectors before any fetch', async () => {
+    let fetches = 0;
+    const client = new MarketplaceRegistryClient({
+      pluginVersion: '3.1.0',
+      fetch: async () => {
+        fetches += 1;
+        return response({});
+      },
+    });
+    await expect(
+      client.download('alvin/deepwork-recon@1.0.0'),
+    ).rejects.toBeInstanceOf(MarketplaceRetiredError);
+    expect(fetches).toBe(0);
+
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-retired-remote-'));
+    try {
+      let downloads = 0;
+      const service = new MarketplaceService({
+        rootDir: root,
+        registryClient: {
+          download: async () => {
+            downloads += 1;
+            throw new Error('registry client should not be called');
+          },
+        },
+      });
+      await expect(
+        service.installRemote('alvin/deepwork-implementer'),
+      ).rejects.toBeInstanceOf(MarketplaceRetiredError);
+      await expect(
+        service.updateRemote('alvin/deepwork-reviewer'),
+      ).rejects.toBeInstanceOf(MarketplaceRetiredError);
+      expect(downloads).toBe(0);
+      expect(existsSync(service.store.paths.lockfilePath)).toBe(false);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
   test('rejects digest, summary, redirect, and bounded responses', async () => {
     const packageBundle = bundle();
     const badIndex = indexFor(packageBundle);

+ 4 - 0
src/marketplace/registry-client.ts

@@ -15,7 +15,9 @@ import {
   MarketplaceRegistryNotFoundError,
   MarketplaceRegistryProtocolError,
   MarketplaceRegistryUnavailableError,
+  MarketplaceRetiredError,
 } from './errors';
+import { assertMarketplacePackageNotRetired } from './retirements';
 import {
   MARKETPLACE_ROLE_CONTRACT_VERSION,
   type MarketplacePackageBundle,
@@ -144,6 +146,7 @@ export class MarketplaceRegistryClient {
         );
       }
     })();
+    assertMarketplacePackageNotRetired(selector.id);
     const index = await this.fetchIndex(signal);
     const matchingId = index.entries.some((entry) => entry.id === selector.id);
     if (!matchingId) {
@@ -174,6 +177,7 @@ export class MarketplaceRegistryClient {
         minimumVersion,
       );
     } catch (error) {
+      if (error instanceof MarketplaceRetiredError) throw error;
       throw new MarketplaceCompatibilityError(
         error instanceof Error ? error.message : String(error),
       );

+ 23 - 0
src/marketplace/retirements.ts

@@ -0,0 +1,23 @@
+import { MarketplaceRetiredError } from './errors';
+
+/** Package IDs that are permanently unavailable for new marketplace state. */
+export const RETIRED_MARKETPLACE_PACKAGE_IDS = [
+  'alvin/deepwork-implementer',
+  'alvin/deepwork-recon',
+  'alvin/deepwork-reviewer',
+] as const;
+
+const retiredPackageIds = new Set<string>(RETIRED_MARKETPLACE_PACKAGE_IDS);
+
+export function isMarketplacePackageRetired(id: string): boolean {
+  return retiredPackageIds.has(id.trim().toLowerCase());
+}
+
+export function assertMarketplacePackageNotRetired(id: string): void {
+  const normalized = id.trim().toLowerCase();
+  if (isMarketplacePackageRetired(normalized)) {
+    throw new MarketplaceRetiredError(
+      `${normalized} is retired and cannot be installed, updated, or activated`,
+    );
+  }
+}

+ 6 - 0
src/marketplace/service.ts

@@ -14,6 +14,7 @@ import {
 } from './errors';
 import { normalizeMarketplacePackageId } from './ids';
 import { MarketplaceRegistryClient } from './registry-client';
+import { assertMarketplacePackageNotRetired } from './retirements';
 import {
   type MarketplacePackageBundle,
   MarketplacePackageBundleSchema,
@@ -91,6 +92,7 @@ export class MarketplaceService {
     source?: MarketplaceSource,
   ): StoredMarketplacePackage {
     const bundle = parseBundle(input);
+    assertMarketplacePackageNotRetired(bundle.manifest.id);
     return this.store.install(bundle, source);
   }
 
@@ -110,6 +112,7 @@ export class MarketplaceService {
     source?: MarketplaceSource,
   ): StoredMarketplacePackage {
     const bundle = parseBundle(input);
+    assertMarketplacePackageNotRetired(bundle.manifest.id);
     return this.store.update(bundle, source);
   }
 
@@ -128,6 +131,8 @@ export class MarketplaceService {
     selector: string,
     signal?: AbortSignal,
   ): Promise<StoredMarketplacePackage> {
+    const selectorId = selector.trim().split('@', 1)[0].toLowerCase();
+    assertMarketplacePackageNotRetired(selectorId);
     const downloaded = await this.registryClient.download(
       selector,
       undefined,
@@ -151,6 +156,7 @@ export class MarketplaceService {
     signal?: AbortSignal,
   ): Promise<StoredMarketplacePackage> {
     const normalizedId = normalizeMarketplacePackageId(id);
+    assertMarketplacePackageNotRetired(normalizedId);
     if (!this.store.getLockfile().packages[normalizedId]) {
       throw new MarketplaceConflictError(
         `${normalizedId} is not installed; updates require an existing package`,

+ 18 - 0
src/marketplace/store.test.ts

@@ -19,6 +19,7 @@ import {
   MarketplaceLockfileError,
   MarketplaceLockOwnershipError,
   type MarketplacePackageBundle,
+  MarketplaceRetiredError,
   MarketplaceService,
   MarketplaceStore,
 } from './index';
@@ -102,6 +103,23 @@ ${body}`,
 }
 
 describe('MarketplaceStore', () => {
+  test('rejects retired local mutations without creating store state', () => {
+    const root = tempRoot();
+    try {
+      const store = new MarketplaceStore({ rootDir: root });
+      expect(() =>
+        store.install(bundle('1.0.0', { id: 'alvin/deepwork-implementer' })),
+      ).toThrow(MarketplaceRetiredError);
+      expect(() =>
+        store.update(bundle('2.0.0', { id: 'alvin/deepwork-reviewer' })),
+      ).toThrow(MarketplaceRetiredError);
+      expect(existsSync(store.paths.lockfilePath)).toBe(false);
+      expect(existsSync(store.paths.packagesDir)).toBe(false);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
   test('installs, verifies, and lists an immutable exact version', () => {
     const root = tempRoot();
     try {

+ 2 - 0
src/marketplace/store.ts

@@ -26,6 +26,7 @@ import {
   writeAtomic,
 } from './lease';
 import { getMarketplacePaths, type MarketplacePaths } from './paths';
+import { assertMarketplacePackageNotRetired } from './retirements';
 import {
   MARKETPLACE_DIGEST_DOMAIN,
   MARKETPLACE_LOCKFILE_SCHEMA_VERSION,
@@ -349,6 +350,7 @@ export class MarketplaceStore {
     mode: 'install' | 'update',
   ): StoredMarketplacePackage {
     const bundle = parseBundle(input);
+    assertMarketplacePackageNotRetired(bundle.manifest.id);
     validateMarketplaceCompatibility(bundle.manifest, this.compatibility);
     const sourceResult = MarketplaceSourceSchema.safeParse(source);
     if (!sourceResult.success) {