pi-codex-marketplace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/extensions/pi/git-registration.ts +138 -0
  4. package/extensions/pi/index.ts +293 -0
  5. package/extensions/pi/installation.ts +90 -0
  6. package/extensions/pi/journal.ts +80 -0
  7. package/extensions/pi/lifecycle.ts +285 -0
  8. package/extensions/pi/registration.ts +143 -0
  9. package/extensions/pi/scope-overrides.ts +170 -0
  10. package/package.json +60 -0
  11. package/src/barrier/global-barrier.ts +105 -0
  12. package/src/bridge-state/atomic.ts +237 -0
  13. package/src/bridge-state/index.ts +5 -0
  14. package/src/bridge-state/migrate.ts +261 -0
  15. package/src/bridge-state/paths.ts +75 -0
  16. package/src/bridge-state/repair.ts +185 -0
  17. package/src/bridge-state/schema.ts +70 -0
  18. package/src/bridge-state/store.ts +489 -0
  19. package/src/bridge-state/types.ts +170 -0
  20. package/src/cache/index.ts +2 -0
  21. package/src/cache/paths.ts +42 -0
  22. package/src/cache/source-cache.ts +365 -0
  23. package/src/compatibility/index.ts +1 -0
  24. package/src/compatibility/profile.ts +328 -0
  25. package/src/installation/flow.ts +443 -0
  26. package/src/installation/index.ts +1 -0
  27. package/src/installation/inspection.ts +129 -0
  28. package/src/journal/active-chains.ts +99 -0
  29. package/src/journal/index.ts +3 -0
  30. package/src/journal/journal.ts +215 -0
  31. package/src/journal/types.ts +49 -0
  32. package/src/lifecycle/index.ts +5 -0
  33. package/src/lifecycle/rebind.ts +290 -0
  34. package/src/lifecycle/refresh.ts +407 -0
  35. package/src/lifecycle/removal.ts +457 -0
  36. package/src/lifecycle/update-plan.ts +222 -0
  37. package/src/lifecycle/update.ts +303 -0
  38. package/src/projection/collision.ts +120 -0
  39. package/src/projection/effective-state.ts +182 -0
  40. package/src/projection/index.ts +4 -0
  41. package/src/projection/overrides.ts +230 -0
  42. package/src/projection/project.ts +359 -0
  43. package/src/reconciliation/startup.ts +144 -0
  44. package/src/registration/budget.ts +28 -0
  45. package/src/registration/catalog.ts +224 -0
  46. package/src/registration/contained.ts +140 -0
  47. package/src/registration/fence.ts +86 -0
  48. package/src/registration/findings.ts +188 -0
  49. package/src/registration/flow.ts +619 -0
  50. package/src/registration/git-acquisition.ts +481 -0
  51. package/src/registration/git-flow.ts +654 -0
  52. package/src/registration/git-locator.ts +380 -0
  53. package/src/registration/git-selector.ts +279 -0
  54. package/src/registration/index.ts +16 -0
  55. package/src/registration/receipt.ts +305 -0
  56. package/src/registration/registration.ts +102 -0
  57. package/src/registration/snapshot.ts +382 -0
  58. package/src/registration/source-key.ts +111 -0
@@ -0,0 +1,443 @@
1
+ /**
2
+ * Plugin Installation lifecycle seam.
3
+ *
4
+ * A preflight holds the scope Attempt Fence and binds the exact State Revision plus Validation
5
+ * Snapshot. `Install Disabled` commits immediately after that disclosure; `Install and Enable`
6
+ * and disabled → enabled require a separate, explicit Activation Confirmation.
7
+ */
8
+
9
+ import type { CompatiblePlugin } from '../compatibility/profile.js';
10
+ import { commitBridgeState, readBridgeState } from '../bridge-state/store.js';
11
+ import type { Installation, Registration, Scope } from '../bridge-state/types.js';
12
+ import { CODE, RULE, blocking, hasBlocking, sortFindings, type ValidationFinding } from '../registration/findings.js';
13
+ import { acquireAttemptFence, type AttemptFenceHandle } from '../registration/fence.js';
14
+ import { createReceipt, type AttemptReceipt } from '../registration/receipt.js';
15
+ import { appendReceipt } from '../journal/journal.js';
16
+ import type { ValidationSnapshot } from '../registration/snapshot.js';
17
+ import { inspectMarketplaceEntries } from './inspection.js';
18
+
19
+ export interface InstallationFlowOptions {
20
+ cwd?: string;
21
+ agentDir?: string;
22
+ projectTrusted?: boolean;
23
+ fenceTimeoutMs?: number;
24
+ /** Integration synchronization seam; production callers leave this undefined. */
25
+ beforeDisableCommit?: () => void | Promise<void>;
26
+ beforeInstallationCommit?: () => void | Promise<void>;
27
+ }
28
+
29
+ export interface PluginInstallationPreflight {
30
+ scope: Scope;
31
+ registration: Registration;
32
+ plugin: CompatiblePlugin;
33
+ snapshot: ValidationSnapshot;
34
+ stateRevision: string;
35
+ findings: ValidationFinding[];
36
+ /** Exact activation material rendered by the TUI before an Activation Confirmation. */
37
+ disclosure: { plugin: CompatiblePlugin; projectedPrecedence: 'Pi → Project Scope → Global Scope'; findings: ValidationFinding[] };
38
+ fence: AttemptFenceHandle;
39
+ terminal: boolean;
40
+ operation: 'install' | 'enable';
41
+ existingInstallation?: Installation;
42
+ }
43
+
44
+ export type InstallationOutcome =
45
+ | { status: 'completed'; installation: Installation; receipt: AttemptReceipt; newRevision: string }
46
+ | { status: 'declined'; receipt: AttemptReceipt }
47
+ | { status: 'blocked'; findings: ValidationFinding[]; receipt: AttemptReceipt }
48
+ | { status: 'rejected-as-stale'; receipt: AttemptReceipt }
49
+ | { status: 'persistence-failed'; receipt: AttemptReceipt; isIndeterminate: boolean };
50
+
51
+ export type InstallationPreflightResult =
52
+ | { ok: true; preflight: PluginInstallationPreflight }
53
+ | { ok: false; outcome: InstallationOutcome };
54
+
55
+ type LifecycleOperation = 'install' | 'enable' | 'disable';
56
+
57
+ function receiptOperation(operation: LifecycleOperation): string {
58
+ return operation === 'install' ? 'Plugin Installation'
59
+ : operation === 'enable' ? 'Plugin Enablement'
60
+ : 'Plugin Disablement';
61
+ }
62
+
63
+ function triggerFor(operation: LifecycleOperation, registrationId: string, entryId: string): string {
64
+ return operation === 'disable' ? `disable ${entryId}` : `${operation} ${registrationId}#${entryId}`;
65
+ }
66
+
67
+ async function blocked(
68
+ operation: LifecycleOperation,
69
+ scope: Scope,
70
+ registrationId: string,
71
+ entryId: string,
72
+ revision: string,
73
+ findings: ValidationFinding[],
74
+ fence: AttemptFenceHandle | null = null,
75
+ opts: InstallationFlowOptions = {},
76
+ ): Promise<InstallationPreflightResult> {
77
+ fence?.release();
78
+ const receipt = createReceipt({
79
+ operation: receiptOperation(operation),
80
+ scope,
81
+ trigger: triggerFor(operation, registrationId, entryId),
82
+ expectedStateRevision: revision,
83
+ summary: 'Blocked',
84
+ findings,
85
+ });
86
+ await appendReceipt(scope, receipt, opts);
87
+ return {
88
+ ok: false,
89
+ outcome: {
90
+ status: 'blocked',
91
+ findings,
92
+ receipt,
93
+ },
94
+ };
95
+ }
96
+
97
+ function scopeDenied(scope: Scope, opts: InstallationFlowOptions): ValidationFinding | undefined {
98
+ if (scope !== 'project' || opts.projectTrusted === true) return undefined;
99
+ return blocking({
100
+ code: CODE.PROJECT_TRUST_DENIED,
101
+ rule: RULE.PROJECT_TRUST_DENIED,
102
+ target: 'installation',
103
+ pointer: '',
104
+ outcome: 'Project Trust is not granted by the Pi host; no Project Scope Lifecycle Operation may mutate Bridge State',
105
+ scope,
106
+ phase: 'admission',
107
+ });
108
+ }
109
+
110
+ function operationFinding(
111
+ scope: Scope,
112
+ code: string,
113
+ rule: string,
114
+ outcome: string,
115
+ target: ValidationFinding['target'] = 'installation',
116
+ phase: ValidationFinding['phase'] = 'validation',
117
+ ): ValidationFinding {
118
+ return blocking({ code, rule, target, pointer: '', outcome, scope, phase });
119
+ }
120
+
121
+ async function makePreflight(
122
+ scope: Scope,
123
+ registrationId: string,
124
+ entryPointer: string,
125
+ opts: InstallationFlowOptions,
126
+ operation: 'install' | 'enable',
127
+ existingInstallation?: Installation,
128
+ ): Promise<InstallationPreflightResult> {
129
+ const read = await readBridgeState(scope, { cwd: opts.cwd, agentDir: opts.agentDir });
130
+ if (read.status !== 'ok' && read.status !== 'missing') {
131
+ const receipt = createReceipt({
132
+ operation: receiptOperation(operation),
133
+ scope,
134
+ trigger: triggerFor(operation, registrationId, entryPointer),
135
+ expectedStateRevision: '?',
136
+ summary: 'Persistence Indeterminate',
137
+ findings: [operationFinding(scope, CODE.PERSISTENCE_INDETERMINATE, 'PERSIST-01', read.error ?? 'Bridge State is not readable', 'attempt', 'persistence')],
138
+ });
139
+ await appendReceipt(scope, receipt, opts);
140
+ return { ok: false, outcome: { status: 'persistence-failed', receipt, isIndeterminate: true } };
141
+ }
142
+ const state = read.state!;
143
+ const trust = scopeDenied(scope, opts);
144
+ if (trust) return blocked(operation, scope, registrationId, entryPointer, state.stateRevision, [trust], null, opts);
145
+ const registration = state.registrations.find((item) => item.id === registrationId);
146
+ if (!registration) {
147
+ return blocked(operation, scope, registrationId, entryPointer, state.stateRevision, [
148
+ operationFinding(scope, CODE.INSTALLATION_NOT_FOUND, RULE.INSTALLATION_NOT_FOUND, `Registration '${registrationId}' is not in ${scope} Bridge State`, 'registration'),
149
+ ], null, opts);
150
+ }
151
+ if (registration.sourceKind !== 'local' || !registration.source) {
152
+ return blocked(operation, scope, registrationId, entryPointer, state.stateRevision, [
153
+ operationFinding(scope, CODE.SOURCE_REACQUISITION_REQUIRED, RULE.SOURCE_REACQUISITION_REQUIRED, 'This Registration has no retained local Validation Snapshot tree; Git Installation waits for the Source Cache lifecycle', 'registration'),
154
+ ], null, opts);
155
+ }
156
+
157
+ const fenceResult = await acquireAttemptFence(scope, { cwd: opts.cwd, agentDir: opts.agentDir, fenceTimeoutMs: opts.fenceTimeoutMs, projectTrusted: opts.projectTrusted });
158
+ if (!fenceResult.ok) return blocked(operation, scope, registrationId, entryPointer, state.stateRevision, [fenceResult.finding!], null, opts);
159
+ const fence = fenceResult.handle!;
160
+
161
+ try {
162
+ const inspection = inspectMarketplaceEntries(registration, scope);
163
+ const inspected = inspection.entries.find((item) => item.entry.entryId === entryPointer);
164
+ const rejectedFindings = inspected?.findings ?? (inspection.findings.length > 0 ? inspection.findings : [
165
+ operationFinding(scope, CODE.INSTALLATION_NOT_FOUND, RULE.INSTALLATION_NOT_FOUND, `Marketplace Entry '${entryPointer}' is Unavailable`, 'entry'),
166
+ ]);
167
+ if (!inspected || !inspection.snapshot || !inspected.plugin || inspected.unavailableReason || hasBlocking(inspected.findings)) {
168
+ return blocked(operation, scope, registrationId, entryPointer, state.stateRevision, rejectedFindings, fence, opts);
169
+ }
170
+ const findings = inspected.findings;
171
+ const plugin = inspected.plugin;
172
+ const installationId = `${scope}/${plugin.id}`;
173
+ const currentInstallation = state.installations.find((item) => item.id === installationId);
174
+ if (operation === 'install' && currentInstallation) {
175
+ return blocked(operation, scope, registrationId, entryPointer, state.stateRevision, [
176
+ operationFinding(scope, CODE.INSTALLATION_ALREADY_EXISTS, RULE.INSTALLATION_ALREADY_EXISTS, `Installation '${installationId}' already exists; use enable or disable lifecycle actions`),
177
+ ], fence, opts);
178
+ }
179
+ if (operation === 'enable' && (!currentInstallation || currentInstallation.installationState !== 'disabled')) {
180
+ return blocked(operation, scope, registrationId, entryPointer, state.stateRevision, [
181
+ operationFinding(scope, CODE.INSTALLATION_NOT_FOUND, RULE.INSTALLATION_NOT_FOUND, `Disabled Installation '${installationId}' is no longer current`),
182
+ ], fence, opts);
183
+ }
184
+ return {
185
+ ok: true,
186
+ preflight: {
187
+ scope,
188
+ registration,
189
+ plugin,
190
+ snapshot: inspection.snapshot,
191
+ stateRevision: state.stateRevision,
192
+ findings,
193
+ disclosure: { plugin, projectedPrecedence: 'Pi → Project Scope → Global Scope', findings },
194
+ fence,
195
+ terminal: false,
196
+ operation,
197
+ existingInstallation: currentInstallation,
198
+ },
199
+ };
200
+ } catch (error) {
201
+ return blocked(operation, scope, registrationId, entryPointer, state.stateRevision, [
202
+ operationFinding(scope, CODE.INSTALLATION_NOT_FOUND, RULE.INSTALLATION_NOT_FOUND, error instanceof Error ? error.message : String(error)),
203
+ ], fence, opts);
204
+ }
205
+ }
206
+
207
+ function disclosureText(value: string): string {
208
+ return JSON.stringify(value);
209
+ }
210
+
211
+ export function installationDisclosure(preflight: PluginInstallationPreflight): string {
212
+ const plugin = preflight.plugin;
213
+ const lines = [
214
+ `Scope: ${preflight.scope}`,
215
+ `Plugin: ${disclosureText(plugin.manifestName)} (${disclosureText(plugin.id)})`,
216
+ `Source: ${disclosureText(preflight.registration.source ?? preflight.registration.canonicalLocator ?? 'unavailable')}`,
217
+ `Marketplace Entry: ${disclosureText(plugin.marketplaceEntryId)}`,
218
+ `State Revision: ${preflight.stateRevision}`,
219
+ `Validation Snapshot: ${preflight.snapshot.fingerprint.slice(0, 16)}…`,
220
+ `Classification: Compatible`,
221
+ `Projected precedence: Pi → Project Scope → Global Scope`,
222
+ `Skills: ${plugin.skills.length}`,
223
+ ];
224
+ for (const skill of plugin.skills) {
225
+ lines.push(` ${disclosureText(skill.name)} · ${skill.invocationPolicy} · resources: ${skill.resources.length === 0 ? 'none' : skill.resources.map(disclosureText).join(', ')}`);
226
+ }
227
+ lines.push(`Findings: ${preflight.findings.length === 0 ? 'none' : preflight.findings.map((finding) => `${finding.classification} ${finding.code}: ${disclosureText(finding.outcome)}`).join(' | ')}`);
228
+ return lines.join('\n');
229
+ }
230
+
231
+ export async function preflightPluginInstallation(
232
+ scope: Scope,
233
+ registrationId: string,
234
+ entryPointer: string,
235
+ opts: InstallationFlowOptions = {},
236
+ ): Promise<InstallationPreflightResult> {
237
+ return makePreflight(scope, registrationId, entryPointer, opts, 'install');
238
+ }
239
+
240
+ async function rejectedAsStale(preflight: PluginInstallationPreflight, outcome: string, opts: InstallationFlowOptions = {}): Promise<InstallationOutcome> {
241
+ preflight.fence.release();
242
+ const receipt = createReceipt({
243
+ operation: receiptOperation(preflight.operation),
244
+ scope: preflight.scope,
245
+ trigger: triggerFor(preflight.operation, preflight.registration.id, preflight.plugin.marketplaceEntryId),
246
+ expectedStateRevision: preflight.stateRevision,
247
+ validationSnapshot: preflight.snapshot.fingerprint,
248
+ summary: 'Rejected as Stale',
249
+ findings: [blocking({
250
+ code: CODE.REJECTED_AS_STALE,
251
+ rule: RULE.REJECTED_AS_STALE,
252
+ target: 'installation',
253
+ pointer: '',
254
+ outcome,
255
+ scope: preflight.scope,
256
+ phase: 'persistence',
257
+ })],
258
+ });
259
+ await appendReceipt(preflight.scope, receipt, opts);
260
+ return {
261
+ status: 'rejected-as-stale',
262
+ receipt,
263
+ };
264
+ }
265
+
266
+ /** Confirm either path. Enabled state is impossible without explicit Activation Confirmation. */
267
+ export async function confirmPluginInstallation(
268
+ preflight: PluginInstallationPreflight,
269
+ targetState: 'enabled' | 'disabled',
270
+ activationConfirmedOrOpts: boolean | InstallationFlowOptions = false,
271
+ maybeOpts: InstallationFlowOptions = {},
272
+ ): Promise<InstallationOutcome> {
273
+ const activationConfirmed = typeof activationConfirmedOrOpts === 'boolean' ? activationConfirmedOrOpts : false;
274
+ const opts = typeof activationConfirmedOrOpts === 'boolean' ? maybeOpts : activationConfirmedOrOpts;
275
+ if (preflight.terminal) {
276
+ const result = await blocked(preflight.operation, preflight.scope, preflight.registration.id, preflight.plugin.marketplaceEntryId, preflight.stateRevision, [
277
+ operationFinding(preflight.scope, CODE.ATTEMPT_IN_PROGRESS, RULE.ATTEMPT_IN_PROGRESS, 'attempt already reached a terminal outcome', 'attempt', 'admission'),
278
+ ], null, opts);
279
+ if (!result.ok) return result.outcome;
280
+ throw new Error('unreachable blocked Installation preflight result');
281
+ }
282
+ preflight.terminal = true;
283
+ if (targetState === 'enabled' && !activationConfirmed) {
284
+ preflight.fence.release();
285
+ const receipt = createReceipt({
286
+ operation: receiptOperation(preflight.operation),
287
+ scope: preflight.scope,
288
+ trigger: triggerFor(preflight.operation, preflight.registration.id, preflight.plugin.marketplaceEntryId),
289
+ expectedStateRevision: preflight.stateRevision,
290
+ validationSnapshot: preflight.snapshot.fingerprint,
291
+ summary: 'Declined',
292
+ findings: [blocking({
293
+ code: CODE.ACTIVATION_CONFIRMATION_REQUIRED,
294
+ rule: RULE.ACTIVATION_CONFIRMATION_REQUIRED,
295
+ target: 'installation',
296
+ pointer: '',
297
+ outcome: 'Install and Enable requires a separate explicit Activation Confirmation (default No)',
298
+ scope: preflight.scope,
299
+ phase: 'admission',
300
+ })],
301
+ });
302
+ await appendReceipt(preflight.scope, receipt, opts);
303
+ return {
304
+ status: 'declined',
305
+ receipt,
306
+ };
307
+ }
308
+ const current = await readBridgeState(preflight.scope, { cwd: opts.cwd, agentDir: opts.agentDir });
309
+ if (current.status !== 'ok' && current.status !== 'missing') {
310
+ preflight.fence.release();
311
+ const receipt = createReceipt({ operation: receiptOperation(preflight.operation), scope: preflight.scope, trigger: triggerFor(preflight.operation, preflight.registration.id, preflight.plugin.marketplaceEntryId), expectedStateRevision: preflight.stateRevision, validationSnapshot: preflight.snapshot.fingerprint, summary: 'Persistence Indeterminate', findings: [operationFinding(preflight.scope, CODE.PERSISTENCE_INDETERMINATE, 'PERSIST-01', current.error ?? 'Bridge State is not readable', 'attempt', 'persistence')] });
312
+ await appendReceipt(preflight.scope, receipt, opts);
313
+ return { status: 'persistence-failed', isIndeterminate: true, receipt };
314
+ }
315
+ if (current.state!.stateRevision !== preflight.stateRevision) {
316
+ return rejectedAsStale(preflight, 'State Revision changed since Installation disclosure; re-run preflight and confirmation', opts);
317
+ }
318
+ const fresh = inspectMarketplaceEntries(preflight.registration, preflight.scope);
319
+ if (!fresh.snapshot || fresh.snapshot.fingerprint !== preflight.snapshot.fingerprint) {
320
+ return rejectedAsStale(preflight, 'Validation Snapshot changed since Installation disclosure; re-run preflight and confirmation', opts);
321
+ }
322
+ const installation: Installation = {
323
+ id: `${preflight.scope}/${preflight.plugin.id}`,
324
+ pluginId: preflight.plugin.id,
325
+ installationState: targetState,
326
+ registrationId: preflight.registration.id,
327
+ marketplaceEntryId: preflight.plugin.marketplaceEntryId,
328
+ validationSnapshot: preflight.snapshot.fingerprint,
329
+ snapshotBinds: { profile: preflight.snapshot.profile, ruleset: preflight.snapshot.ruleset, budget: preflight.snapshot.budget },
330
+ manifestName: preflight.plugin.manifestName,
331
+ };
332
+ await opts.beforeInstallationCommit?.();
333
+ const write = await commitBridgeState(preflight.scope, (state) => ({
334
+ ...state,
335
+ installations: preflight.operation === 'enable'
336
+ ? state.installations.map((item) => item.id === installation.id ? installation : item)
337
+ : [...state.installations, installation],
338
+ }), { cwd: opts.cwd, agentDir: opts.agentDir, lockTimeoutMs: opts.fenceTimeoutMs, expectedStateRevision: preflight.stateRevision });
339
+ if (write.isStale) return rejectedAsStale(preflight, 'State Revision changed after Installation confirmation; re-run preflight and confirmation', opts);
340
+ preflight.fence.release();
341
+ if (!write.success) {
342
+ const summary = write.isIndeterminate ? 'Persistence Indeterminate' : 'Persistence Failed';
343
+ const receipt = createReceipt({ operation: receiptOperation(preflight.operation), scope: preflight.scope, trigger: triggerFor(preflight.operation, preflight.registration.id, preflight.plugin.marketplaceEntryId), expectedStateRevision: preflight.stateRevision, validationSnapshot: preflight.snapshot.fingerprint, summary, findings: [] });
344
+ await appendReceipt(preflight.scope, receipt, opts);
345
+ return {
346
+ status: 'persistence-failed',
347
+ isIndeterminate: write.isIndeterminate ?? false,
348
+ receipt,
349
+ };
350
+ }
351
+ const receipt = createReceipt({ operation: receiptOperation(preflight.operation), scope: preflight.scope, trigger: triggerFor(preflight.operation, preflight.registration.id, preflight.plugin.marketplaceEntryId), expectedStateRevision: preflight.stateRevision, targetStateRevision: write.newRevision, observedStateRevision: write.newRevision, validationSnapshot: preflight.snapshot.fingerprint, summary: preflight.findings.length > 0 ? 'Completed with diagnostics' : 'Completed', findings: preflight.findings, stateChanged: true });
352
+ await appendReceipt(preflight.scope, receipt, opts);
353
+ return {
354
+ status: 'completed',
355
+ installation,
356
+ newRevision: write.newRevision!,
357
+ receipt,
358
+ };
359
+ }
360
+
361
+ export async function preflightPluginEnable(scope: Scope, installationId: string, opts: InstallationFlowOptions = {}): Promise<InstallationPreflightResult> {
362
+ const read = await readBridgeState(scope, { cwd: opts.cwd, agentDir: opts.agentDir });
363
+ if (read.status !== 'ok' && read.status !== 'missing') {
364
+ const receipt = createReceipt({ operation: receiptOperation('enable'), scope, trigger: `enable ${installationId}`, expectedStateRevision: '?', summary: 'Persistence Indeterminate', findings: [operationFinding(scope, CODE.PERSISTENCE_INDETERMINATE, 'PERSIST-01', read.error ?? 'Bridge State is not readable', 'attempt', 'persistence')] });
365
+ await appendReceipt(scope, receipt, opts);
366
+ return { ok: false, outcome: { status: 'persistence-failed', receipt, isIndeterminate: true } };
367
+ }
368
+ const existing = read.state?.installations.find((item) => item.id === installationId);
369
+ if (!existing?.registrationId || !existing.marketplaceEntryId) {
370
+ return blocked('enable', scope, 'unknown', installationId, read.state?.stateRevision ?? '?', [
371
+ operationFinding(scope, CODE.INSTALLATION_NOT_FOUND, RULE.INSTALLATION_NOT_FOUND, `Disabled Installation '${installationId}' has no revalidatable provenance`),
372
+ ], null, opts);
373
+ }
374
+ const prefix = `${existing.registrationId}/`;
375
+ const entryPointer = existing.marketplaceEntryId.startsWith(prefix)
376
+ ? existing.marketplaceEntryId.slice(existing.marketplaceEntryId.indexOf('/plugins/'))
377
+ : existing.marketplaceEntryId;
378
+ return makePreflight(scope, existing.registrationId, entryPointer, opts, 'enable', existing);
379
+ }
380
+
381
+ export async function confirmPluginEnable(preflight: PluginInstallationPreflight, activationConfirmed: boolean, opts: InstallationFlowOptions = {}): Promise<InstallationOutcome> {
382
+ return confirmPluginInstallation(preflight, 'enabled', activationConfirmed, opts);
383
+ }
384
+
385
+ export async function disablePluginInstallation(scope: Scope, installationId: string, opts: InstallationFlowOptions = {}): Promise<InstallationOutcome> {
386
+ const trust = scopeDenied(scope, opts);
387
+ if (trust) {
388
+ const result = await blocked('disable', scope, 'unknown', installationId, '?', [trust], null, opts);
389
+ if (!result.ok) return result.outcome;
390
+ throw new Error('unreachable blocked Installation preflight result');
391
+ }
392
+ const fence = await acquireAttemptFence(scope, { cwd: opts.cwd, agentDir: opts.agentDir, fenceTimeoutMs: opts.fenceTimeoutMs, projectTrusted: opts.projectTrusted });
393
+ if (!fence.ok) {
394
+ const result = await blocked('disable', scope, 'unknown', installationId, '?', [fence.finding!], null, opts);
395
+ if (!result.ok) return result.outcome;
396
+ throw new Error('unreachable blocked Installation preflight result');
397
+ }
398
+ const read = await readBridgeState(scope, { cwd: opts.cwd, agentDir: opts.agentDir });
399
+ if (read.status !== 'ok' && read.status !== 'missing') {
400
+ fence.handle!.release();
401
+ const receipt = createReceipt({ operation: receiptOperation('disable'), scope, trigger: triggerFor('disable', 'unknown', installationId), expectedStateRevision: '?', summary: 'Persistence Indeterminate', findings: [operationFinding(scope, CODE.PERSISTENCE_INDETERMINATE, 'PERSIST-01', read.error ?? 'Bridge State is not readable', 'attempt', 'persistence')] });
402
+ await appendReceipt(scope, receipt, opts);
403
+ return { status: 'persistence-failed', isIndeterminate: true, receipt };
404
+ }
405
+ const state = read.state!;
406
+ const installation = state.installations.find((item) => item.id === installationId);
407
+ if (!installation) {
408
+ const result = await blocked('disable', scope, 'unknown', installationId, state.stateRevision, [operationFinding(scope, CODE.INSTALLATION_NOT_FOUND, RULE.INSTALLATION_NOT_FOUND, `Installation '${installationId}' was not found`)], fence.handle!, opts);
409
+ if (!result.ok) return result.outcome;
410
+ throw new Error('unreachable blocked Installation preflight result');
411
+ }
412
+ await opts.beforeDisableCommit?.();
413
+ const write = await commitBridgeState(scope, (state) => ({
414
+ ...state,
415
+ installations: state.installations.map((item) => item.id === installationId ? { ...item, installationState: 'disabled' } : item),
416
+ }), { cwd: opts.cwd, agentDir: opts.agentDir, lockTimeoutMs: opts.fenceTimeoutMs, expectedStateRevision: state.stateRevision });
417
+ fence.handle!.release();
418
+ if (write.isStale) {
419
+ const receipt = createReceipt({
420
+ operation: receiptOperation('disable'),
421
+ scope,
422
+ trigger: triggerFor('disable', 'unknown', installationId),
423
+ expectedStateRevision: state.stateRevision,
424
+ observedStateRevision: write.observedRevision,
425
+ summary: 'Rejected as Stale',
426
+ findings: [operationFinding(scope, CODE.REJECTED_AS_STALE, RULE.REJECTED_AS_STALE, 'State Revision changed after disablement admission; re-run the lifecycle operation', 'installation', 'persistence')],
427
+ });
428
+ await appendReceipt(scope, receipt, opts);
429
+ return {
430
+ status: 'rejected-as-stale',
431
+ receipt,
432
+ };
433
+ }
434
+ if (!write.success) {
435
+ const receipt = createReceipt({ operation: receiptOperation('disable'), scope, trigger: triggerFor('disable', 'unknown', installationId), expectedStateRevision: state.stateRevision, summary: write.isIndeterminate ? 'Persistence Indeterminate' : 'Persistence Failed' });
436
+ await appendReceipt(scope, receipt, opts);
437
+ return { status: 'persistence-failed', isIndeterminate: write.isIndeterminate ?? false, receipt };
438
+ }
439
+ const disabled = { ...installation, installationState: 'disabled' as const };
440
+ const receipt = createReceipt({ operation: receiptOperation('disable'), scope, trigger: triggerFor('disable', 'unknown', installationId), expectedStateRevision: state.stateRevision, targetStateRevision: write.newRevision, observedStateRevision: write.newRevision, summary: 'Completed', stateChanged: true });
441
+ await appendReceipt(scope, receipt, opts);
442
+ return { status: 'completed', installation: disabled, newRevision: write.newRevision!, receipt };
443
+ }
@@ -0,0 +1 @@
1
+ export * from './flow.js';
@@ -0,0 +1,129 @@
1
+ /** Read-only Marketplace Entry inspection.
2
+ *
3
+ * This is deliberately separate from lifecycle preflight: it never acquires an Attempt Fence,
4
+ * creates a receipt, or reads Bridge State. One bounded snapshot and one catalog parse produce
5
+ * all browse results for a Marketplace.
6
+ */
7
+
8
+ import { createHash } from 'node:crypto';
9
+ import { lstatSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ import { classifyPlugin, type CompatiblePlugin } from '../compatibility/profile.js';
13
+ import type { Registration, Scope } from '../bridge-state/types.js';
14
+ import type { MarketplaceEntry } from '../registration/catalog.js';
15
+ import { parseCatalog } from '../registration/catalog.js';
16
+ import { resolveContained } from '../registration/contained.js';
17
+ import { CODE, RULE, blocking, hasBlocking, sortFindings, type ValidationFinding } from '../registration/findings.js';
18
+ import { localSourceKey } from '../registration/source-key.js';
19
+ import { bindCapturedMaterial, buildLocalSnapshot, type ValidationSnapshot } from '../registration/snapshot.js';
20
+ import { BUDGET } from '../registration/budget.js';
21
+
22
+ export interface InspectedMarketplaceEntry {
23
+ entry: MarketplaceEntry;
24
+ plugin?: CompatiblePlugin;
25
+ findings: ValidationFinding[];
26
+ unavailableReason?: string;
27
+ }
28
+
29
+ export interface MarketplaceInspection {
30
+ entries: InspectedMarketplaceEntry[];
31
+ findings: ValidationFinding[];
32
+ marketplaceId?: string;
33
+ snapshot?: ValidationSnapshot;
34
+ /** Fingerprint of the inspected source tree alone, before activation-material binding.
35
+ * Registrations persist exactly this value at confirmation (their preflight snapshot is
36
+ * the unbound tree fingerprint from buildLocalSnapshot); Source Drift compares against it. */
37
+ treeFingerprint?: string;
38
+ }
39
+
40
+ export interface InspectionOptions {
41
+ /** Inspect this explicit root with this caller-bound base Validation Snapshot (e.g. a Git acquisition tree) instead of deriving a local one from the Registration. */
42
+ root?: string;
43
+ baseSnapshot?: ValidationSnapshot;
44
+ /** Suppress the recorded-snapshot drift Blocking Finding — Refresh compares fingerprints explicitly instead. */
45
+ ignoreRecordedDrift?: boolean;
46
+ }
47
+
48
+ function inspectionFinding(scope: Scope, code: string, rule: string, target: ValidationFinding['target'], outcome: string): ValidationFinding {
49
+ return blocking({ code, rule, target, pointer: '', outcome, scope, phase: 'validation' });
50
+ }
51
+
52
+ /** Inspect every Marketplace Entry once. All filesystem work occurs after a bounded snapshot. */
53
+ export function inspectMarketplaceEntries(registration: Registration, scope: Scope, opts: InspectionOptions = {}): MarketplaceInspection {
54
+ const override = Boolean(opts.root && opts.baseSnapshot);
55
+ let root: string;
56
+ let snapshotResult: { ok: boolean; snapshot?: ValidationSnapshot; findings: ValidationFinding[] };
57
+ if (override) {
58
+ root = opts.root!;
59
+ snapshotResult = { ok: true, snapshot: opts.baseSnapshot, findings: [] };
60
+ } else {
61
+ if (registration.sourceKind !== 'local' || !registration.source) {
62
+ return { entries: [], findings: [inspectionFinding(scope, CODE.SOURCE_REACQUISITION_REQUIRED, RULE.SOURCE_REACQUISITION_REQUIRED, 'registration', 'Git Source Cache lifecycle is not available yet')] };
63
+ }
64
+ const key = localSourceKey(registration.source);
65
+ if (!key.ok) return { entries: [], findings: [inspectionFinding(scope, CODE.SOURCE_REACQUISITION_REQUIRED, RULE.SOURCE_REACQUISITION_REQUIRED, 'registration', key.error ?? 'Marketplace Root cannot be revalidated')] };
66
+ root = key.sourceKey!.canonicalPath!;
67
+ const catalogPath = join(root, '.agents', 'plugins', 'marketplace.json');
68
+ try {
69
+ if (lstatSync(catalogPath).size > BUDGET.maxCatalogBytes) {
70
+ return { entries: [], findings: [inspectionFinding(scope, CODE.BUDGET_EXCEEDED, RULE.BUDGET_EXCEEDED, 'catalog', `Validation Budget exceeded: catalog exceeds ${BUDGET.maxCatalogBytes} bytes`)] };
71
+ }
72
+ } catch {
73
+ // The catalog read below provides the stable catalog-missing finding.
74
+ }
75
+ snapshotResult = buildLocalSnapshot(root, key.sourceKey!, scope);
76
+ if (!snapshotResult.ok) return { entries: [], findings: snapshotResult.findings };
77
+ }
78
+
79
+ const catalogPath = join(root, '.agents', 'plugins', 'marketplace.json');
80
+
81
+ let catalogRaw: string;
82
+ let catalogValue: unknown;
83
+ try {
84
+ const bytes = readFileSync(catalogPath);
85
+ if (bytes.length > BUDGET.maxCatalogBytes) return { entries: [], snapshot: snapshotResult.snapshot, findings: [inspectionFinding(scope, CODE.BUDGET_EXCEEDED, RULE.BUDGET_EXCEEDED, 'catalog', `Validation Budget exceeded: catalog exceeds ${BUDGET.maxCatalogBytes} bytes`)] };
86
+ catalogRaw = bytes.toString('utf8');
87
+ catalogValue = JSON.parse(catalogRaw);
88
+ } catch {
89
+ return { entries: [], snapshot: snapshotResult.snapshot, findings: [inspectionFinding(scope, CODE.CATALOG_MISSING, RULE.CATALOG_MISSING, 'catalog', 'Marketplace Catalog cannot be read')] };
90
+ }
91
+ const parsed = parseCatalog(catalogValue, { scope });
92
+ if (!parsed.catalog) return { entries: [], snapshot: snapshotResult.snapshot, findings: parsed.findings };
93
+ const marketplaceId = `${registration.id}/${parsed.catalog.name}`;
94
+ const drift = !opts.ignoreRecordedDrift && registration.validationSnapshot && registration.validationSnapshot !== snapshotResult.snapshot!.fingerprint
95
+ ? [inspectionFinding(scope, CODE.REJECTED_AS_STALE, RULE.REJECTED_AS_STALE_SNAPSHOT, 'registration', 'Registered Validation Snapshot no longer matches the source tree; Marketplace Refresh is required')]
96
+ : [];
97
+ const material = createHash('sha256');
98
+ material.update('catalog\u001f').update(catalogRaw).update('\u001e');
99
+ const classifications = new Map<string, ReturnType<typeof classifyPlugin>>();
100
+ const draft = parsed.catalog.entries.map((entry) => {
101
+ if (!entry.available || entry.type !== 'local' || !entry.path) return { entry, findings: [], unavailableReason: entry.unavailableReason ?? 'unsupported source kind' };
102
+ const contained = resolveContained(root, entry.path, 'directory');
103
+ if (contained.outcome.kind !== 'ok') return { entry, findings: [], unavailableReason: 'cannot resolve Plugin' };
104
+ let baseClassification = classifications.get(contained.outcome.canonicalPath);
105
+ if (!baseClassification) {
106
+ baseClassification = classifyPlugin(contained.outcome.canonicalPath, { scope, marketplaceId, marketplaceEntryId: `${marketplaceId}${entry.entryId}` });
107
+ classifications.set(contained.outcome.canonicalPath, baseClassification);
108
+ }
109
+ const classification = baseClassification.plugin
110
+ ? { ...baseClassification, plugin: { ...baseClassification.plugin, marketplaceEntryId: `${marketplaceId}${entry.entryId}` } }
111
+ : baseClassification;
112
+ material.update(`entry:${entry.entryId}\u001f`).update(classification.captureFingerprint).update('\u001e');
113
+ return { entry, plugin: classification.plugin, identity: classification.identity, findings: classification.findings };
114
+ });
115
+ const snapshot = bindCapturedMaterial(snapshotResult.snapshot!, material.digest('hex'));
116
+ const identities = new Map<string, number>();
117
+ for (const item of draft) if (item.identity) identities.set(item.identity, (identities.get(item.identity) ?? 0) + 1);
118
+ const entries = draft.map((item): InspectedMarketplaceEntry => {
119
+ const collision = item.identity && (identities.get(item.identity) ?? 0) > 1
120
+ ? [inspectionFinding(scope, CODE.PLUGIN_ID_COLLISION, RULE.PLUGIN_ID_COLLISION, 'plugin', `Plugin ID '${item.identity}' collides with another Marketplace Entry; neither entry is activatable`)]
121
+ : [];
122
+ const catalogFindings = parsed.findings.filter((finding) => finding.target !== 'entry' || finding.pointer === item.entry.entryId);
123
+ const findings = sortFindings([...catalogFindings, ...item.findings, ...collision, ...drift, ...snapshotResult.findings]);
124
+ const unavailableReason = item.unavailableReason
125
+ ?? (hasBlocking(findings) || !item.plugin ? findings.find((finding) => finding.classification === 'blocking')?.outcome ?? 'incompatible' : undefined);
126
+ return { entry: item.entry, plugin: item.plugin, findings, unavailableReason };
127
+ });
128
+ return { entries, findings: sortFindings([...parsed.findings, ...drift, ...snapshotResult.findings]), marketplaceId, snapshot, treeFingerprint: snapshotResult.snapshot!.fingerprint };
129
+ }