pi-codex-marketplace 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-codex-marketplace
2
2
 
3
- Bridge Package for Codex Marketplace compatibility in Pi (`Pi 0.84.2`).
3
+ Bridge Package for Codex and Claude Marketplace compatibility in Pi (`Pi 0.84.2`).
4
4
 
5
5
  > **One-line:** `pi install npm:pi-codex-marketplace` → `/codex-marketplace` opens a responsive Bridge Ledger with Global authority, guarded lifecycle transactions, and three-orthogonal Receipt reporting.
6
6
 
@@ -42,9 +42,9 @@ Every mutation uses the shared transaction sequence **`Intent → Validation →
42
42
 
43
43
  The Ledger exposes these existing capabilities without changing their lifecycle semantics:
44
44
 
45
- - **檢視 Global 分區** — single `Global Scope` (`{getAgentDir()}/codex-marketplace/state.json`) workspace. Shows `schemaVersion` (v2), `stateRevision`, registrations/installations, empty-state guidance, and provenance notes.
46
- - **註冊本地 / Git Marketplace…** — local or Git Marketplace Source Registration. Local uses canonical real path; Git uses Canonical Git Locator (credential-free) + Normalized Git Selector (`default` / `branch refs/heads/*` / `tag refs/tags/*` / `commit` lower 40/64 hex) → Resolved Revision. Validation Disclosure shows source, Marketplace name, State Revision, Validation Snapshot fingerprint, entry outcomes, findings summary. **Registration Confirmation** is snapshot+revision bound, Default No. Blocking Findings (Contained Path / Contained Symlink / Budget / duplicate Source Key / locator/selector trust) block the attempt; concurrent attempt is blocked by Attempt Fence (`FENCE-01`); changed State Revision or snapshot yields `Rejected as Stale` (`STALE-01/02`).
47
- - **安裝 Compatible Plugin…** — the Plugins ledger is keyed directly by stable **Marketplace Entry ID** (`/plugins/<序號>`), classification, skills, Invocation Policies, and resources; each compatible row exposes `Install Disabled` and `Install and Enable` without reopening a display-label Entry selector. Unavailable rows remain visible with their exact reason (unsupported source kind / parse failure / Invalid / Incompatible / Plugin ID collision). Compatibility Profile v1 classifies every Plugin atomically as Compatible / Incompatible / Invalid; collision never changes classification. `Install Disabled` persists provenance without Activation Confirmation; `Install and Enable` discloses exact Plugin, skill list, Skill Resources, Invocation Policies, `Pi → Global` precedence and findings, then requires a separate **Default No** Activation Confirmation (bound to same snapshot+revision). Re-enabling a disabled Installation repeats validation and confirmation; disabling preserves its Installation ID.
45
+ - **檢視 Global 分區** — single `Global Scope` (`{getAgentDir()}/codex-marketplace/state.json`) workspace. Shows `schemaVersion` (v3), `stateRevision`, registrations/installations, empty-state guidance, and provenance notes.
46
+ - **註冊本地 / Git Marketplace…** — local or Git Marketplace Source Registration supporting dual Marketplace Formats (`codex` via `.agents/plugins/marketplace.json` and `claude` via `.claude-plugin/marketplace.json`, with codex precedence when both exist; missing both fails closed as `CATALOG_MISSING`). The derived `format` is fixed to the Registration record. Local uses canonical real path; Git uses Canonical Git Locator (credential-free) + Normalized Git Selector (`default` / `branch refs/heads/*` / `tag refs/tags/*` / `commit` lower 40/64 hex) → Resolved Revision. Validation Disclosure shows source, Marketplace name, Marketplace Format, State Revision, Validation Snapshot fingerprint, entry outcomes, findings summary. **Registration Confirmation** is snapshot+revision bound, Default No. Blocking Findings (Contained Path / Contained Symlink / Budget / duplicate Source Key / locator/selector trust) block the attempt; concurrent attempt is blocked by Attempt Fence (`FENCE-01`); changed State Revision or snapshot yields `Rejected as Stale` (`STALE-01/02`).
47
+ - **安裝 Compatible Plugin…** — the Plugins ledger is keyed directly by stable **Marketplace Entry ID** (`/plugins/<序號>`), classification, skills, Invocation Policies, and resources; each compatible row exposes `Install Disabled` and `Install and Enable` without reopening a display-label Entry selector. Unavailable rows remain visible with their exact reason (unsupported source kind / entry-defined strict:false / parse failure / Invalid / Incompatible / Plugin ID collision). Compatibility Profile v2 classifies every Plugin atomically across Codex and Claude manifests as Compatible / Incompatible / Invalid; collision never changes classification. `Install Disabled` persists provenance without Activation Confirmation; `Install and Enable` discloses exact Plugin, skill list, Skill Resources, Invocation Policies, `Pi → Global` precedence and findings, then requires a separate **Default No** Activation Confirmation (bound to same snapshot+revision). Re-enabling a disabled Installation repeats validation and confirmation; disabling preserves its Installation ID.
48
48
  - **管理已安裝 Plugin(Enable / Disable)…** — toggle Installation State; disabling preserves provenance, enabling re-validates under current profile/ruleset/budget.
49
49
  - **檢視 Effective State 與 Projected Skills…** — read-time derived Effective State (Global Scope enabled installations) and Projected Skills with Runtime Skill Collision resolution (`Pi → Global Scope` exact name layering: pre-existing Pi skills reserve their names; non-colliding Bridge skills are projected; colliding Bridge skills within the same layer are unavailable). Whole-Plugin Blocking Findings block the Plugin; collision affects only that skill; `Available` is established only by independent host evidence (`AVAIL-01`).
50
50
  - **Refresh / 更新 Registration… (Marketplace Refresh → Update Candidate → Update Plan Checklist → Apply Update)** — Refresh is non-mutating and produces an Update Candidate when the validated source state differs (Plugin version alone does not; full-commit Git selector ref movement alone does not). Update Plan Checklist requires fresh **Registration Confirmation** + one explicit outcome per Installation (`update` / `disable` / `remove`, with `update` only when a Compatible candidate exists) + **Activation Confirmation** per enabled installation that remains enabled. Commit is a single atomic Lifecycle Operation replacing the Registration's Validation Snapshot and applying every disclosed consequence without mixing revisions.
@@ -61,9 +61,9 @@ Bridge State is the sole authority, stored in a **single Global Scope document**
61
61
 
62
62
  ```jsonc
63
63
  {
64
- "schemaVersion": 2,
64
+ "schemaVersion": 3,
65
65
  "stateRevision": "1", // opaque monotonic (string "0" -> "1" -> "2" ...)
66
- "registrations": [], // immutable Registration ID = UUIDv4 allocated before preflight
66
+ "registrations": [], // immutable Registration ID = UUIDv4, with format: "codex" | "claude"
67
67
  "installations": [] // Installed Plugins (enabled/disabled), each bound to a Validation Snapshot
68
68
  }
69
69
  ```
@@ -73,7 +73,7 @@ Only authoritative fields are persisted. `Effective State`, catalogs, compatibil
73
73
  - `State Revision` increments monotonically under file lock; commit is CAS-guarded by `expectedStateRevision` when supplied.
74
74
  - Writes are **atomic**: `write-to-temp → fsync → rename` + `fsync` parent dir + **file lock** (`.lock` sibling) + **WAL** (`state.json.wal`) + **read-after-verify**.
75
75
  - Cross-process concurrency is safe: lock serializes RMW; rename prevents torn reads; per-fingerprint `flock` guards Source Cache fetches with `p50 <200ms` on cache hit.
76
- - **WAL migration** (`src/bridge-state/migrate.ts`): supported forward migrations are applied atomically via WAL (`state.json.wal` fsynced before commit, replayed on the next read after a crash, cleaned after commit success). Migrations are **non-waivable, opt-in per version**, require no implicit activation, and preserve the active recovery chain. Schema v1 → v2 migration strips legacy `scopeOverrides` (non-empty overrides generate a non-blocking `MIGRATE-01` diagnostic finding without fail-closed) and normalizes Installation IDs (stripping legacy `global/` prefix). Unknown/older-without-path and newer (`schemaVersion > CURRENT_SCHEMA_VERSION`) versions are treated as **incompatible** — fail-closed, no auto-migration, no rollback. **Downgrade never writes back** (`isDowngradeAttempt` guard): a newer durable file is never overwritten by an older Bridge Package; the operator must update the package first.
76
+ - **WAL migration** (`src/bridge-state/migrate.ts`): supported forward migrations are applied atomically via WAL (`state.json.wal` fsynced before commit, replayed on the next read after a crash, cleaned after commit success). Migrations are **non-waivable, opt-in per version**, require no implicit activation, and preserve the active recovery chain. Schema v1 → v2 migration strips legacy `scopeOverrides` (non-empty overrides generate a non-blocking `MIGRATE-01` diagnostic finding without fail-closed) and normalizes Installation IDs (stripping legacy `global/` prefix). Schema v2 → v3 migration populates `format: "codex"` on existing registrations without data loss while newly written registrations store `format: "codex" | "claude"`. Unknown/older-without-path and newer (`schemaVersion > CURRENT_SCHEMA_VERSION`) versions are treated as **incompatible** — fail-closed, no auto-migration, no rollback. **Downgrade never writes back** (`isDowngradeAttempt` guard): a newer durable file is never overwritten by an older Bridge Package; the operator must update the package first.
77
77
  - Corrupted / unknown `schemaVersion` → classified as **corrupted / incompatible (Persistence Indeterminate)**, **never auto-rollback** — fail-closed. `validateSchema` and `migrateForward` enforce the closed set `CORRUPTED_JSON` / `INVALID_SCHEMA` / `INCOMPATIBLE_SCHEMA_VERSION` / `UNKNOWN_OLD_VERSION` / `MIGRATION_FAILED`.
78
78
 
79
79
  See `src/bridge-state/` for `store.ts`, `atomic.ts`, `schema.ts`, `migrate.ts`, `paths.ts`.
@@ -113,7 +113,7 @@ Every row is a **release blocker**: `v*` may not publish unless the full matrix
113
113
  | E2E (highest seam — **TUI**) | pinned | macOS + Linux | 22.19.0 | **0.84.2** | full lifecycle (Register → Install Disabled / Install and Enable → Disable/Enable → Refresh → Update Plan Checklist → Apply Update / Rebind → Removal) with fence/cache/external observability |
114
114
  | E2E (highest seam — **TUI**) | adversarial | macOS + Linux | 22.19.0 | **0.84.2** | collision (`Pi → Global`), cache (`offline exact fingerprint hit` vs `stale never success`) |
115
115
 
116
- Fixtures: `tests/fixtures/synthetic/`, `tests/fixtures/pinned/` (captured `SamWang32191/codex-plugins@98e78ca` snapshot + fingerprint manifest), `tests/fixtures/adversarial/` (path-escape / symlink-loop / budget-overflow / malformed frontmatter corpora). See `tests/acceptance/` for the matrix runner that enforces per-row gating (any row failure blocks publish).
116
+ Fixtures: `tests/fixtures/synthetic/` (including synthetic Claude and mattpocock-shaped marketplace fixtures), `tests/fixtures/pinned/` (captured `SamWang32191/codex-plugins@98e78ca` snapshot + fingerprint manifest), `tests/fixtures/adversarial/` (path-escape / symlink-loop / budget-overflow / malformed frontmatter corpora). See `tests/acceptance/` for the matrix runner that enforces per-row gating (any row failure blocks publish).
117
117
 
118
118
  Run locally:
119
119
 
@@ -14,9 +14,10 @@ import type { ExtensionCommandContext, ExtensionUIContext } from '@earendil-work
14
14
  import {
15
15
  preflightGitRegistration,
16
16
  confirmGitRegistration,
17
+ type GitRegistrationFlowOptions,
17
18
  } from '../../src/registration/git-flow.js';
18
19
  import type { GitSelectorInput } from '../../src/registration/git-selector.js';
19
- import { uiText } from './ui-strings.js';
20
+ import { marketplaceFormatText, uiText } from './ui-strings.js';
20
21
  import {
21
22
  fullValidationDisclosureLines,
22
23
  reportOutcome,
@@ -36,8 +37,14 @@ async function transactionStep(
36
37
  return false;
37
38
  }
38
39
 
40
+ /**
41
+ * One interactive git registration flow invocation.
42
+ * The optional flow options thread the Acquisition Trust Base seams (executor / trust / cache)
43
+ * for tests and embedders; production callers rely on the real Git executor.
44
+ */
39
45
  export async function runGitRegistrationFlow(
40
46
  ctx: ExtensionCommandContext,
47
+ flowOpts: Pick<GitRegistrationFlowOptions, 'executor' | 'trust' | 'cache'> = {},
41
48
  ): Promise<void> {
42
49
  const ui: ExtensionUIContext = ctx.ui;
43
50
 
@@ -99,7 +106,7 @@ export async function runGitRegistrationFlow(
99
106
  ],
100
107
  })) return;
101
108
 
102
- const opts = {};
109
+ const opts: GitRegistrationFlowOptions = { ...flowOpts };
103
110
  const res = await preflightGitRegistration(locator, selectorInput, opts);
104
111
  if (!res.ok) {
105
112
  await reportTerminalPreflightOutcome(ctx, res.outcome);
@@ -121,6 +128,7 @@ export async function runGitRegistrationFlow(
121
128
  }),
122
129
  uiText('reg.git.detail.resolvedRevision', { revision: quoteTerminalText(pf.resolvedRevision) }),
123
130
  uiText('reg.detail.marketplace', { name: quoteTerminalText(pf.marketplaceName) }),
131
+ uiText('reg.detail.format', { format: marketplaceFormatText(pf.format) }),
124
132
  uiText('reg.detail.entries', {
125
133
  total: pf.catalog.entries.length,
126
134
  locatable: pf.catalog.entries.filter((entry) => entry.available).length,
@@ -18,7 +18,7 @@ import { acquireAttemptFence } from '../../src/registration/fence.js';
18
18
  import { blocking, CODE, notice, RULE, type ValidationFinding } from '../../src/registration/findings.js';
19
19
  import { createReceipt, type AttemptReceipt } from '../../src/registration/receipt.js';
20
20
  import { fullValidationDisclosureLines, reportOutcome } from './registration.js';
21
- import { attemptSummaryText, uiText } from './ui-strings.js';
21
+ import { attemptSummaryText, marketplaceFormatText, uiText } from './ui-strings.js';
22
22
  import { quoteTerminalText } from './terminal-presentation.js';
23
23
  import { openTransactionSheet, type TransactionSheetModel } from './transaction-sheet.js';
24
24
 
@@ -478,6 +478,9 @@ export async function runReceiptJournalView(
478
478
  expected: quote(rc.expectedStateRevision),
479
479
  observed: quote(rc.observedStateRevision ?? rc.targetStateRevision ?? '?'),
480
480
  }));
481
+ if (rc.marketplaceFormat) {
482
+ lines.push(' ' + uiText('journal.view.receipt.format', { format: marketplaceFormatText(rc.marketplaceFormat) }));
483
+ }
481
484
  if (rc.recoversReceiptId) {
482
485
  lines.push(' ' + uiText('journal.view.receipt.recovers', { receiptId: quote(rc.recoversReceiptId) }));
483
486
  }
@@ -17,7 +17,7 @@ import {
17
17
  } from '../../src/registration/flow.js';
18
18
  import { sortFindings, type ValidationFinding } from '../../src/registration/findings.js';
19
19
  import type { AttemptReceipt } from '../../src/registration/receipt.js';
20
- import { attemptSummaryText, findingOutcomeText, uiText, verdictText } from './ui-strings.js';
20
+ import { attemptSummaryText, findingOutcomeText, marketplaceFormatText, uiText, verdictText } from './ui-strings.js';
21
21
  import { quoteTerminalText } from './terminal-presentation.js';
22
22
  import { openTransactionSheet, type TransactionSheetModel } from './transaction-sheet.js';
23
23
 
@@ -101,6 +101,7 @@ export async function runLocalRegistrationFlow(
101
101
  uiText('reg.detail.registrationId', { id: quoteTerminalText(pf.registrationId) }),
102
102
  uiText('reg.detail.source', { source: quoteTerminalText(pf.canonicalPath) }),
103
103
  uiText('reg.detail.marketplace', { name: quoteTerminalText(pf.marketplaceName) }),
104
+ uiText('reg.detail.format', { format: marketplaceFormatText(pf.format) }),
104
105
  uiText('reg.detail.entries', {
105
106
  total: pf.catalog.entries.length,
106
107
  locatable: pf.catalog.entries.filter((entry) => entry.available).length,
@@ -210,8 +211,12 @@ export async function reportOutcome(
210
211
  validationSnapshot: rc.validationSnapshot,
211
212
  receipt: rc,
212
213
  });
214
+ const notifyMessage = rc.marketplaceFormat
215
+ ? `${uiText('reg.outcome.notify', { summary: attemptSummaryText(rc.summary), receiptId: quoteTerminalText(rc.id) })} · ${
216
+ uiText('reg.outcome.notify.formatSuffix', { format: marketplaceFormatText(rc.marketplaceFormat) })}`
217
+ : uiText('reg.outcome.notify', { summary: attemptSummaryText(rc.summary), receiptId: quoteTerminalText(rc.id) });
213
218
  ctx.ui.notify(
214
- uiText('reg.outcome.notify', { summary: attemptSummaryText(rc.summary), receiptId: quoteTerminalText(rc.id) }),
219
+ notifyMessage,
215
220
  notifyType,
216
221
  );
217
222
  }
@@ -13,6 +13,7 @@ import {
13
13
  attemptSummaryGloss,
14
14
  closedValue,
15
15
  findingOutcomeText,
16
+ marketplaceFormatText,
16
17
  recoveryActionGloss,
17
18
  transactionStepLabel,
18
19
  uiText,
@@ -98,6 +99,9 @@ function receiptAxes(receipt: AttemptReceipt, theme: TransactionSheetTheme): [st
98
99
  field(theme, uiText('sheet.field.kind'), receipt.kind),
99
100
  field(theme, uiText('sheet.field.operation'), receipt.operation),
100
101
  field(theme, uiText('sheet.field.trigger'), receipt.trigger),
102
+ ...(receipt.marketplaceFormat === undefined
103
+ ? []
104
+ : [field(theme, uiText('sheet.field.marketplaceFormat'), marketplaceFormatText(receipt.marketplaceFormat))]),
101
105
  ];
102
106
  return [durable, findings, runtime];
103
107
  }
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { AttemptSummary, RecoveryAction } from '../../src/registration/receipt.js';
18
+ import type { MarketplaceFormat } from '../../src/bridge-state/types.js';
18
19
  import type { ValidationFinding } from '../../src/registration/findings.js';
19
20
  import type { TransactionStep } from './transaction-sheet.js';
20
21
 
@@ -52,6 +53,8 @@ const zhTW = {
52
53
 
53
54
  // --- closed-value glosses (canonical always rendered first) ---------------
54
55
 
56
+ 'format.gloss.codex': 'Codex 格式',
57
+ 'format.gloss.claude': 'Claude 格式',
55
58
  'summary.gloss.Completed': '完成',
56
59
  'summary.gloss.Completed with diagnostics': '完成但有診斷',
57
60
  'summary.gloss.Declined': '已婉拒',
@@ -114,6 +117,7 @@ const zhTW = {
114
117
  'sheet.field.kind': '種類',
115
118
  'sheet.field.operation': '操作',
116
119
  'sheet.field.trigger': '觸發',
120
+ 'sheet.field.marketplaceFormat': 'Marketplace Format',
117
121
  'sheet.attemptSummary': 'Attempt Summary',
118
122
  'sheet.recoveryActions': 'Recovery Actions(復原動作)',
119
123
  'sheet.keys': 'Enter:繼續|Esc/q/Ctrl-C:取消',
@@ -225,6 +229,7 @@ const zhTW = {
225
229
  'reg.detail.registrationId': 'Registration ID {id}',
226
230
  'reg.detail.source': '來源 {source}',
227
231
  'reg.detail.marketplace': 'Marketplace {name}',
232
+ 'reg.detail.format': 'Marketplace Format {format}',
228
233
  'reg.detail.entries': 'Entries {total}(可定位 {locatable} / 無法定位 {unavailable})',
229
234
  'reg.detail.profile': 'Compatibility Profile {profile}',
230
235
  'reg.detail.ruleset': 'Validation Ruleset {ruleset}',
@@ -263,6 +268,7 @@ const zhTW = {
263
268
  'reg.commit.persist': '寫入 Registration ID {id}',
264
269
  'reg.commit.authority': '於 {scope} 以 State Revision {revision} 寫入授權文件',
265
270
  'reg.outcome.notify': 'Attempt Summary:{summary} · Receipt {receiptId}',
271
+ 'reg.outcome.notify.formatSuffix': 'Marketplace Format {format}',
266
272
 
267
273
  // --- installation.ts ---------------------------------------------------------
268
274
  'inst.select.registered': '選擇已註冊 Marketplace',
@@ -390,6 +396,7 @@ const zhTW = {
390
396
  'journal.view.recent.empty': '(Journal 為空)',
391
397
  'journal.view.receipt.summary': '摘要:{summary}|持久:{durable}|運行時:{runtime}',
392
398
  'journal.view.receipt.revision': 'State Revision:{expected} → {observed}',
399
+ 'journal.view.receipt.format': 'Marketplace Format:{format}',
393
400
  'journal.view.receipt.recovers': '復原對象:{receiptId}',
394
401
  'journal.view.receipt.findings': 'Findings:{findings}',
395
402
  'journal.notFound': 'Receipt Journal 找不到精確 Receipt ID {receiptId}。',
@@ -560,6 +567,11 @@ export function recoveryActionGloss(action: RecoveryAction): string {
560
567
  return glossId ? uiText(glossId) : '';
561
568
  }
562
569
 
570
+ /** Marketplace Format closed value presented canonical-first with its zh_TW gloss. */
571
+ export function marketplaceFormatText(format: MarketplaceFormat): string {
572
+ return closedValue(format, uiText(format === 'claude' ? 'format.gloss.claude' : 'format.gloss.codex'));
573
+ }
574
+
563
575
  const STEP_GLOSS: Record<TransactionStep, MessageId> = {
564
576
  Intent: 'step.gloss.Intent',
565
577
  Validation: 'step.gloss.Validation',
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "pi-codex-marketplace",
3
- "version": "0.2.0",
4
- "description": "Bridge Package for Codex Marketplace compatibility in Pi — Global Bridge State, atomic persistence, and /codex-marketplace TUI",
3
+ "version": "0.3.0",
4
+ "description": "Bridge Package for Codex and Claude Marketplace compatibility in Pi — Global Bridge State, atomic persistence, and /codex-marketplace TUI",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "pi-package",
8
8
  "pi",
9
9
  "pi-coding-agent",
10
10
  "codex",
11
+ "claude",
11
12
  "marketplace"
12
13
  ],
13
14
  "license": "MIT",
@@ -48,6 +48,21 @@ const MIGRATIONS: Record<number, Migrator> = {
48
48
  }
49
49
  return { state: cloned, findings };
50
50
  },
51
+ 2: (state: any) => {
52
+ // Issue #44 — registration `format` attribute (codex | claude) persistence foundation.
53
+ // Backfill a lossless 'codex' default onto every Registration missing a valid format; any
54
+ // already-declared 'codex' | 'claude' value is preserved untouched (no content loss).
55
+ const cloned = structuredClone(state);
56
+ if (Array.isArray(cloned.registrations)) {
57
+ cloned.registrations = cloned.registrations.map((reg: any) => {
58
+ if (typeof reg !== 'object' || reg === null) return reg;
59
+ if (reg.format === 'codex' || reg.format === 'claude') return reg;
60
+ return { ...reg, format: 'codex' };
61
+ });
62
+ }
63
+ cloned.schemaVersion = 3;
64
+ return { state: cloned };
65
+ },
51
66
  };
52
67
 
53
68
  export interface MigrationResult {
@@ -11,15 +11,23 @@
11
11
 
12
12
  import type { ValidationFinding } from '../registration/findings.js';
13
13
 
14
- export const CURRENT_SCHEMA_VERSION = 2;
14
+ export const CURRENT_SCHEMA_VERSION = 3;
15
15
 
16
16
  /** Opaque monotonic identifier. Stored as decimal string, incremented on each successful commit. */
17
17
  export type StateRevision = string;
18
18
 
19
+ /** Marketplace Format — codex | claude (persistence foundation, issue #44).
20
+ * Format detection / claude parsing is delivered by later tickets; the persistence
21
+ * layer carries the value through and legacy registrations are backfilled to 'codex'
22
+ * by the v2→v3 migration. Absence (pre-v3) always reads back as 'codex' after migration. */
23
+ export type MarketplaceFormat = 'codex' | 'claude';
24
+
19
25
  /** Minimal shape for scaffold — later tickets extend with Source Key, Validation Snapshot, etc. */
20
26
  export interface Registration {
21
27
  /** Immutable lowercase UUIDv4, allocated before preflight */
22
28
  id: string;
29
+ /** Marketplace Format carried by this Registration. v2→v3 migration backfills 'codex' for legacy records. */
30
+ format?: MarketplaceFormat;
23
31
  /** Human-readable alias, derived from marketplace name */
24
32
  alias?: string;
25
33
  /** Declared marketplace name (kebab-case) */
@@ -54,6 +62,8 @@ export interface Registration {
54
62
  resolvedRevision?: string;
55
63
  /** Validation Snapshot fingerprint bound to Registration Confirmation */
56
64
  validationSnapshot?: string;
65
+ /** Per-entry Validation Snapshot fingerprints bound to Registration Confirmation (issue #50). */
66
+ entrySnapshots?: Record<string, string>;
57
67
  /** Bound Compatibility Profile / Ruleset / Budget ids at confirmation time */
58
68
  snapshotBinds?: { profile?: string; ruleset?: string; budget?: string };
59
69
  }
@@ -59,6 +59,7 @@ export interface CacheIndexRecord {
59
59
 
60
60
  export interface PendingUpdateRecord {
61
61
  registrationId: string;
62
+ entryId?: string;
62
63
  fingerprint: string;
63
64
  recordedAtMs: number;
64
65
  }
@@ -254,7 +255,7 @@ export class SourceCache {
254
255
  // ---- Pending Update Candidate registry ---------------------------------
255
256
 
256
257
  recordPendingUpdate(rec: Omit<PendingUpdateRecord, 'recordedAtMs'>): void {
257
- const records = this.pendingUpdates().filter((r) => !(r.registrationId === rec.registrationId));
258
+ const records = this.pendingUpdates().filter((r) => !(r.registrationId === rec.registrationId && r.entryId === rec.entryId));
258
259
  records.push({ ...rec, recordedAtMs: this.now() });
259
260
  atomicWriteFile(getCachePendingPath(this.root), JSON.stringify(records));
260
261
  }