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,619 @@
1
+ /**
2
+ * Local Marketplace Registration flow — the Lifecycle Operation seam for #17.
3
+ * See CONTEXT.md: Marketplace Registration, Registration Confirmation, Validation Disclosure,
4
+ * Attempt Fence, Rejected as Stale, Attempt Receipt, Project Trust.
5
+ *
6
+ * Flow: preflight (fence → identity → validation → disclosure) → Registration Confirmation
7
+ * (snapshot + State Revision bound, Default No) → scope-atomic commit → Attempt Receipt.
8
+ *
9
+ * The TUI layer renders the Validation Disclosure from the preflight and forwards the user's
10
+ * explicit yes/no to `confirmLocalRegistration`; confirmation is never remembered or batched.
11
+ */
12
+
13
+ import { readFileSync, statSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ import { commitBridgeState, readBridgeState } from '../bridge-state/store.js';
17
+ import type { Registration, Scope } from '../bridge-state/types.js';
18
+ import { BUDGET } from './budget.js';
19
+ import {
20
+ CODE,
21
+ RULE,
22
+ blocking,
23
+ hasBlocking,
24
+ sortFindings,
25
+ type ValidationFinding,
26
+ } from './findings.js';
27
+ import { parseCatalog, type Catalog } from './catalog.js';
28
+ import { resolveContained } from './contained.js';
29
+ import { acquireAttemptFence, type AttemptFenceHandle } from './fence.js';
30
+ import { createReceipt, type AttemptReceipt } from './receipt.js';
31
+ import { appendReceipt } from '../journal/journal.js';
32
+ import {
33
+ allocateRegistrationId,
34
+ deriveInitialAlias,
35
+ findDuplicateRegistration,
36
+ sourceKeyForLocalRoot,
37
+ } from './registration.js';
38
+ import { buildLocalSnapshot, type ValidationSnapshot } from './snapshot.js';
39
+ import type { SourceKey } from './source-key.js';
40
+
41
+ export const MARKETPLACE_CATALOG_RELPATH = '.agents/plugins/marketplace.json';
42
+
43
+ export interface RegistrationFlowOptions {
44
+ cwd?: string;
45
+ agentDir?: string;
46
+ /** Host-owned decision (ctx.isProjectTrusted()). Without it, Project scope operations are blocked. */
47
+ projectTrusted?: boolean;
48
+ /** Deterministic Registration ID for tests; otherwise UUIDv4 allocated before preflight. */
49
+ preallocatedId?: string;
50
+ fenceTimeoutMs?: number;
51
+ }
52
+
53
+ export interface LocalRegistrationPreflight {
54
+ scope: Scope;
55
+ /** Immutable lowercase UUIDv4 allocated before preflight validation. */
56
+ registrationId: string;
57
+ alias?: string;
58
+ sourceKey: SourceKey;
59
+ canonicalPath: string;
60
+ marketplaceName: string;
61
+ catalog: Catalog;
62
+ snapshot: ValidationSnapshot;
63
+ findings: ValidationFinding[];
64
+ blocked: boolean;
65
+ /** State Revision observed at preflight — bound to confirmation. */
66
+ stateRevision: string;
67
+ fence: AttemptFenceHandle;
68
+ /** True once confirm/cancel reached a terminal outcome (single terminal per preflight). */
69
+ terminal: boolean;
70
+ }
71
+
72
+ export type RegistrationOutcome =
73
+ | {
74
+ status: 'completed';
75
+ registration: Registration;
76
+ receipt: AttemptReceipt;
77
+ newRevision: string;
78
+ }
79
+ | { status: 'declined'; receipt: AttemptReceipt }
80
+ | { status: 'blocked'; findings: ValidationFinding[]; receipt: AttemptReceipt; existing?: Registration }
81
+ | { status: 'rejected-as-stale'; receipt: AttemptReceipt }
82
+ | { status: 'persistence-failed'; receipt: AttemptReceipt; isIndeterminate: boolean };
83
+
84
+ export type PreflightResult =
85
+ | { ok: true; preflight: LocalRegistrationPreflight }
86
+ | { ok: false; outcome: RegistrationOutcome };
87
+
88
+ const OPERATION = 'Marketplace Registration';
89
+
90
+ function triggerFor(rootPath: string): string {
91
+ return `register local ${rootPath}`;
92
+ }
93
+
94
+ /** Build a Blocked preflight result: immutable redacted receipt + journal append + released fence (if held). */
95
+ async function blockedResult(
96
+ scope: Scope,
97
+ rootPath: string,
98
+ expectedRevision: string,
99
+ findings: ValidationFinding[],
100
+ handle: AttemptFenceHandle | null,
101
+ opts: RegistrationFlowOptions = {},
102
+ existing?: Registration,
103
+ ): Promise<{ ok: false; outcome: RegistrationOutcome }> {
104
+ if (handle) handle.release();
105
+ const receipt = createReceipt({
106
+ operation: OPERATION,
107
+ scope,
108
+ trigger: triggerFor(rootPath),
109
+ expectedStateRevision: expectedRevision,
110
+ summary: 'Blocked',
111
+ findings,
112
+ });
113
+ await appendReceipt(scope, receipt, opts);
114
+ return { ok: false, outcome: { status: 'blocked', findings, receipt, existing } };
115
+ }
116
+
117
+ async function readScopeState(scope: Scope, opts: RegistrationFlowOptions) {
118
+ return readBridgeState(scope, { cwd: opts.cwd, agentDir: opts.agentDir });
119
+ }
120
+
121
+ /**
122
+ * Run preflight for a local Marketplace Root registration.
123
+ * Acquires the per-scope Attempt Fence, allocates the Registration ID before validation, validates
124
+ * catalog / paths / symlinks / budget, builds the Validation Snapshot, detects duplicates, and
125
+ * produces the Validation Disclosure material. The fence is held until confirm/cancel.
126
+ */
127
+ export async function preflightLocalRegistration(
128
+ scope: Scope,
129
+ rootPath: string,
130
+ opts: RegistrationFlowOptions = {},
131
+ ): Promise<PreflightResult> {
132
+ const read = await readScopeState(scope, opts);
133
+ let expectedRevision = '0';
134
+ let registrations: Registration[] = [];
135
+ if (read.status === 'missing') {
136
+ expectedRevision = read.state!.stateRevision;
137
+ } else if (read.status === 'ok') {
138
+ expectedRevision = read.state!.stateRevision;
139
+ registrations = read.state!.registrations;
140
+ } else {
141
+ // corrupted / incompatible — fail-closed, no attempt may proceed
142
+ const receipt = createReceipt({
143
+ operation: OPERATION,
144
+ scope,
145
+ trigger: triggerFor(rootPath),
146
+ expectedStateRevision: '?',
147
+ summary: 'Persistence Indeterminate',
148
+ findings: [
149
+ blocking({
150
+ code: CODE.PERSISTENCE_INDETERMINATE,
151
+ phase: 'persistence',
152
+ target: 'registration',
153
+ scope,
154
+ pointer: '',
155
+ rule: 'PERSIST-01',
156
+ outcome: read.error ?? `state is ${read.status}; neither previous nor target verifiable`,
157
+ }),
158
+ ],
159
+ });
160
+ await appendReceipt(scope, receipt, opts);
161
+ return {
162
+ ok: false,
163
+ outcome: { status: 'persistence-failed', receipt, isIndeterminate: true },
164
+ };
165
+ }
166
+
167
+ // Project Trust: host-owned. Fail-closed: an omitted trust flag is NOT a grant. Untrusted
168
+ // project scope blocks the operation; stored records stay durable and are only excluded from
169
+ // derived Effective State.
170
+ if (scope === 'project' && opts.projectTrusted !== true) {
171
+ const finding = blocking({
172
+ code: CODE.PROJECT_TRUST_DENIED,
173
+ phase: 'admission',
174
+ target: 'registration',
175
+ scope,
176
+ pointer: '',
177
+ rule: RULE.PROJECT_TRUST_DENIED,
178
+ outcome:
179
+ 'Project Trust is not granted by the Pi host; project records remain stored but excluded from Effective State, and no Project Scope Lifecycle Operation may mutate them',
180
+ });
181
+ return blockedResult(scope, rootPath, expectedRevision, [finding], null, opts);
182
+ }
183
+
184
+ // Attempt Fence (same-scope exclusivity + global barrier on project)
185
+ const fence = await acquireAttemptFence(scope, {
186
+ cwd: opts.cwd,
187
+ agentDir: opts.agentDir,
188
+ fenceTimeoutMs: opts.fenceTimeoutMs,
189
+ projectTrusted: opts.projectTrusted,
190
+ });
191
+ if (!fence.ok) {
192
+ return blockedResult(scope, rootPath, expectedRevision, [fence.finding!], null, opts);
193
+ }
194
+ const handle = fence.handle!;
195
+
196
+ try {
197
+ // Registration ID is allocated before preflight validation (stable derived identities).
198
+ const registrationId = opts.preallocatedId ?? allocateRegistrationId();
199
+
200
+ // Source Key (canonical real path)
201
+ const sourceKeyRes = sourceKeyForLocalRoot(rootPath, scope);
202
+ if (!sourceKeyRes.ok) {
203
+ return blockedResult(scope, rootPath, expectedRevision, sourceKeyRes.findings, handle, opts);
204
+ }
205
+ const sourceKey = sourceKeyRes.sourceKey;
206
+ const canonicalPath = sourceKey.canonicalPath!;
207
+
208
+ // Marketplace Catalog — canonical `.agents/plugins/marketplace.json`
209
+ const findings: ValidationFinding[] = [];
210
+ const catalogPath = join(canonicalPath, MARKETPLACE_CATALOG_RELPATH);
211
+ let catalogBytes = 0;
212
+ try {
213
+ catalogBytes = statSync(catalogPath).size;
214
+ } catch {
215
+ const finding = blocking({
216
+ code: CODE.CATALOG_MISSING,
217
+ phase: 'validation',
218
+ target: 'catalog',
219
+ scope,
220
+ pointer: MARKETPLACE_CATALOG_RELPATH,
221
+ rule: RULE.CATALOG_MISSING,
222
+ outcome: `Marketplace Catalog not found at ${MARKETPLACE_CATALOG_RELPATH}; legacy marketplace shapes do not participate in Bridge ingestion`,
223
+ });
224
+ return blockedResult(scope, rootPath, expectedRevision, [finding], handle, opts);
225
+ }
226
+ if (catalogBytes > BUDGET.maxCatalogBytes) {
227
+ const finding = blocking({
228
+ code: CODE.BUDGET_EXCEEDED,
229
+ phase: 'validation',
230
+ target: 'catalog',
231
+ scope,
232
+ pointer: MARKETPLACE_CATALOG_RELPATH,
233
+ rule: RULE.BUDGET_EXCEEDED,
234
+ outcome: `Validation Budget exceeded: catalog ${catalogBytes} bytes > ${BUDGET.maxCatalogBytes}`,
235
+ });
236
+ return blockedResult(scope, rootPath, expectedRevision, [finding], handle, opts);
237
+ }
238
+
239
+ let parsed: unknown;
240
+ try {
241
+ parsed = JSON.parse(readFileSync(catalogPath, 'utf-8'));
242
+ } catch (e) {
243
+ const msg = e instanceof Error ? e.message : String(e);
244
+ const finding = blocking({
245
+ code: CODE.CATALOG_MALFORMED,
246
+ phase: 'validation',
247
+ target: 'catalog',
248
+ scope,
249
+ pointer: MARKETPLACE_CATALOG_RELPATH,
250
+ rule: RULE.CATALOG_MALFORMED,
251
+ outcome: `unable to parse marketplace.json: ${msg}`,
252
+ });
253
+ return blockedResult(scope, rootPath, expectedRevision, [finding], handle, opts);
254
+ }
255
+
256
+ const catalogResult = parseCatalog(parsed, { scope });
257
+ findings.push(...catalogResult.findings);
258
+ if (!catalogResult.ok) {
259
+ return blockedResult(scope, rootPath, expectedRevision, sortFindings(findings), handle, opts);
260
+ }
261
+ const catalog = catalogResult.catalog!;
262
+
263
+ if (catalog.name.length > BUDGET.maxNameLength) {
264
+ findings.push(
265
+ blocking({
266
+ code: CODE.BUDGET_EXCEEDED,
267
+ phase: 'validation',
268
+ target: 'catalog',
269
+ scope,
270
+ pointer: '/name',
271
+ rule: RULE.BUDGET_EXCEEDED,
272
+ outcome: `Validation Budget exceeded: marketplace name ${catalog.name.length} chars > ${BUDGET.maxNameLength}`,
273
+ }),
274
+ );
275
+ }
276
+ if (catalog.entries.length > BUDGET.maxEntries) {
277
+ findings.push(
278
+ blocking({
279
+ code: CODE.BUDGET_EXCEEDED,
280
+ phase: 'validation',
281
+ target: 'catalog',
282
+ scope,
283
+ pointer: '/plugins',
284
+ rule: RULE.BUDGET_EXCEEDED,
285
+ outcome: `Validation Budget exceeded: ${catalog.entries.length} entries > ${BUDGET.maxEntries}`,
286
+ }),
287
+ );
288
+ }
289
+
290
+ // Containment resolution per local entry (Unavailable Entries are disclosed, not findings).
291
+ for (const entry of catalog.entries) {
292
+ if (entry.type !== 'local' || !entry.path) continue;
293
+ const res = resolveContained(canonicalPath, entry.path, 'any');
294
+ if (res.outcome.kind === 'blocking') {
295
+ const reason = res.outcome.reason;
296
+ const symlink = res.outcome.blockClass === 'symlink';
297
+ findings.push(
298
+ blocking({
299
+ code: symlink ? CODE.CONTAINED_SYMLINK_VIOLATION : CODE.PATH_CONTAINMENT_VIOLATION,
300
+ phase: 'validation',
301
+ target: 'entry',
302
+ scope,
303
+ pointer: `${entry.entryId}/path`,
304
+ rule: symlink ? RULE.CONTAINED_SYMLINK_VIOLATION : RULE.PATH_CONTAINMENT_VIOLATION,
305
+ outcome: `${entry.entryId}: ${reason}`,
306
+ }),
307
+ );
308
+ entry.available = false;
309
+ entry.unavailableReason = reason;
310
+ } else if (res.outcome.kind === 'missing') {
311
+ entry.available = false;
312
+ entry.unavailableReason = 'cannot resolve to a Plugin: target does not exist';
313
+ }
314
+ }
315
+
316
+ // Validation Snapshot (complete tree + binds)
317
+ const snapshotResult = buildLocalSnapshot(canonicalPath, sourceKey, scope);
318
+ findings.push(...snapshotResult.findings);
319
+
320
+ const sorted = sortFindings(findings);
321
+ if (hasBlocking(sorted)) {
322
+ return blockedResult(scope, rootPath, expectedRevision, sorted, handle, opts);
323
+ }
324
+
325
+ // Duplicate detection (same kind + identical Source Key); directs to the existing Registration.
326
+ const dup = findDuplicateRegistration(scope, sourceKey, registrations);
327
+ if (dup.duplicate) {
328
+ return blockedResult(scope, rootPath, expectedRevision, [dup.finding!], handle, opts, dup.existing);
329
+ }
330
+
331
+ const alias = deriveInitialAlias(
332
+ catalog.name,
333
+ registrations.map((r) => r.alias).filter((a): a is string => Boolean(a)),
334
+ );
335
+
336
+ const preflight: LocalRegistrationPreflight = {
337
+ scope,
338
+ registrationId,
339
+ alias,
340
+ sourceKey,
341
+ canonicalPath,
342
+ marketplaceName: catalog.name,
343
+ catalog,
344
+ snapshot: snapshotResult.snapshot!,
345
+ findings: sorted,
346
+ blocked: false,
347
+ stateRevision: expectedRevision,
348
+ fence: handle,
349
+ terminal: false,
350
+ };
351
+ return { ok: true, preflight };
352
+ } catch (e) {
353
+ handle.release();
354
+ const msg = e instanceof Error ? e.message : String(e);
355
+ const finding = blocking({
356
+ code: 'PREFLIGHT_ERROR',
357
+ phase: 'validation',
358
+ target: 'registration',
359
+ scope,
360
+ pointer: '',
361
+ rule: 'PREFLIGHT-01',
362
+ outcome: `preflight failed: ${msg}`,
363
+ });
364
+ return blockedResult(scope, rootPath, expectedRevision, [finding], null, opts);
365
+ }
366
+ }
367
+
368
+ function snapshotBinds(snapshot: ValidationSnapshot) {
369
+ return { profile: snapshot.profile, ruleset: snapshot.ruleset, budget: snapshot.budget };
370
+ }
371
+
372
+ /**
373
+ * Registration Confirmation — bound to the preflight's Validation Snapshot + State Revision,
374
+ * Default No, never remembered or applied in bulk.
375
+ */
376
+ export async function confirmLocalRegistration(
377
+ preflight: LocalRegistrationPreflight,
378
+ yes: boolean,
379
+ opts: RegistrationFlowOptions = {},
380
+ ): Promise<RegistrationOutcome> {
381
+ if (preflight.terminal) {
382
+ const receipt = createReceipt({
383
+ operation: OPERATION,
384
+ scope: preflight.scope,
385
+ trigger: triggerFor(preflight.canonicalPath),
386
+ expectedStateRevision: preflight.stateRevision,
387
+ validationSnapshot: preflight.snapshot.fingerprint,
388
+ summary: 'Blocked',
389
+ findings: [
390
+ blocking({
391
+ code: CODE.ATTEMPT_IN_PROGRESS,
392
+ phase: 'admission',
393
+ target: 'attempt',
394
+ scope: preflight.scope,
395
+ pointer: '',
396
+ rule: RULE.ATTEMPT_IN_PROGRESS,
397
+ outcome: 'attempt already reached a terminal outcome',
398
+ }),
399
+ ],
400
+ });
401
+ await appendReceipt(preflight.scope, receipt, opts);
402
+ return { status: 'blocked', findings: [], receipt };
403
+ }
404
+ preflight.terminal = true;
405
+ const { fence, scope } = preflight;
406
+ const release = () => fence.release();
407
+
408
+ if (!yes) {
409
+ const receipt = createReceipt({
410
+ operation: OPERATION,
411
+ scope,
412
+ trigger: triggerFor(preflight.canonicalPath),
413
+ expectedStateRevision: preflight.stateRevision,
414
+ validationSnapshot: preflight.snapshot.fingerprint,
415
+ summary: 'Declined',
416
+ findings: preflight.findings,
417
+ stateChanged: false,
418
+ });
419
+ await appendReceipt(scope, receipt, opts);
420
+ release();
421
+ return { status: 'declined', receipt };
422
+ }
423
+
424
+ // Re-verify exact State Revision (bound confirmation). Any change ⇒ Rejected as Stale.
425
+ const fresh = await readScopeState(scope, opts);
426
+ if (fresh.status !== 'ok' && fresh.status !== 'missing') {
427
+ const receipt = createReceipt({
428
+ operation: OPERATION,
429
+ scope,
430
+ trigger: triggerFor(preflight.canonicalPath),
431
+ expectedStateRevision: preflight.stateRevision,
432
+ validationSnapshot: preflight.snapshot.fingerprint,
433
+ summary: 'Persistence Indeterminate',
434
+ findings: [
435
+ blocking({
436
+ code: CODE.PERSISTENCE_INDETERMINATE,
437
+ phase: 'persistence',
438
+ target: 'registration',
439
+ scope,
440
+ pointer: '',
441
+ rule: 'PERSIST-01',
442
+ outcome: fresh.error ?? `state is ${fresh.status}; neither previous nor target verifiable`,
443
+ }),
444
+ ],
445
+ });
446
+ await appendReceipt(scope, receipt, opts);
447
+ release();
448
+ return { status: 'persistence-failed', receipt, isIndeterminate: true };
449
+ }
450
+ const currentRevision = fresh.state!.stateRevision;
451
+ if (currentRevision !== preflight.stateRevision) {
452
+ const receipt = createReceipt({
453
+ operation: OPERATION,
454
+ scope,
455
+ trigger: triggerFor(preflight.canonicalPath),
456
+ expectedStateRevision: preflight.stateRevision,
457
+ targetStateRevision: currentRevision,
458
+ validationSnapshot: preflight.snapshot.fingerprint,
459
+ summary: 'Rejected as Stale',
460
+ findings: [
461
+ blocking({
462
+ code: CODE.REJECTED_AS_STALE,
463
+ phase: 'persistence',
464
+ target: 'attempt',
465
+ scope,
466
+ pointer: '',
467
+ rule: RULE.REJECTED_AS_STALE,
468
+ outcome: `State Revision changed (${preflight.stateRevision} → ${currentRevision}); re-run preflight and confirmation — no automatic merge`,
469
+ }),
470
+ ],
471
+ stateChanged: false,
472
+ });
473
+ await appendReceipt(scope, receipt, opts);
474
+ release();
475
+ return { status: 'rejected-as-stale', receipt };
476
+ }
477
+
478
+ // Re-check duplicates under the fresh state (a concurrent commit may have created one).
479
+ const dup = findDuplicateRegistration(scope, preflight.sourceKey, fresh.state!.registrations);
480
+ if (dup.duplicate) {
481
+ const receipt = createReceipt({
482
+ operation: OPERATION,
483
+ scope,
484
+ trigger: triggerFor(preflight.canonicalPath),
485
+ expectedStateRevision: preflight.stateRevision,
486
+ validationSnapshot: preflight.snapshot.fingerprint,
487
+ summary: 'Blocked',
488
+ findings: [dup.finding!],
489
+ });
490
+ await appendReceipt(scope, receipt, opts);
491
+ release();
492
+ return { status: 'blocked', findings: [dup.finding!], receipt, existing: dup.existing };
493
+ }
494
+
495
+ // Re-verify the Validation Snapshot fingerprint against the live tree.
496
+ const revalidated = buildLocalSnapshot(preflight.canonicalPath, preflight.sourceKey, scope);
497
+ if (!revalidated.ok || revalidated.snapshot!.fingerprint !== preflight.snapshot.fingerprint) {
498
+ const receipt = createReceipt({
499
+ operation: OPERATION,
500
+ scope,
501
+ trigger: triggerFor(preflight.canonicalPath),
502
+ expectedStateRevision: preflight.stateRevision,
503
+ validationSnapshot: preflight.snapshot.fingerprint,
504
+ summary: 'Rejected as Stale',
505
+ findings: [
506
+ blocking({
507
+ code: CODE.REJECTED_AS_STALE,
508
+ phase: 'validation',
509
+ target: 'registration',
510
+ scope,
511
+ pointer: '/',
512
+ rule: RULE.REJECTED_AS_STALE_SNAPSHOT,
513
+ outcome:
514
+ 'Validation Snapshot fingerprint changed since disclosure (source tree drifted); re-run preflight and confirmation — no automatic merge',
515
+ }),
516
+ ],
517
+ stateChanged: false,
518
+ });
519
+ await appendReceipt(scope, receipt, opts);
520
+ release();
521
+ return { status: 'rejected-as-stale', receipt };
522
+ }
523
+
524
+ const registration: Registration = {
525
+ id: preflight.registrationId,
526
+ alias: preflight.alias,
527
+ marketplaceName: preflight.marketplaceName,
528
+ sourceKind: 'local',
529
+ source: preflight.canonicalPath,
530
+ sourceKey: preflight.sourceKey,
531
+ validationSnapshot: preflight.snapshot.fingerprint,
532
+ snapshotBinds: snapshotBinds(preflight.snapshot),
533
+ };
534
+
535
+ const write = await commitBridgeState(
536
+ scope,
537
+ (current) => ({ ...current, registrations: [...current.registrations, registration] }),
538
+ { cwd: opts.cwd, agentDir: opts.agentDir, lockTimeoutMs: opts.fenceTimeoutMs ?? 5000 },
539
+ );
540
+ if (!write.success) {
541
+ const summary: 'Persistence Failed' | 'Persistence Indeterminate' = write.isIndeterminate
542
+ ? 'Persistence Indeterminate'
543
+ : 'Persistence Failed';
544
+ const receipt = createReceipt({
545
+ operation: OPERATION,
546
+ scope,
547
+ trigger: triggerFor(preflight.canonicalPath),
548
+ expectedStateRevision: preflight.stateRevision,
549
+ targetStateRevision: '?',
550
+ validationSnapshot: preflight.snapshot.fingerprint,
551
+ summary,
552
+ findings: [
553
+ blocking({
554
+ code: write.isIndeterminate ? CODE.PERSISTENCE_INDETERMINATE : CODE.PERSISTENCE_FAILED,
555
+ phase: 'persistence',
556
+ target: 'registration',
557
+ scope,
558
+ pointer: '',
559
+ rule: write.isIndeterminate ? 'PERSIST-01' : 'PERSIST-02',
560
+ outcome: write.error ?? summary,
561
+ }),
562
+ ],
563
+ stateChanged: false,
564
+ });
565
+ await appendReceipt(scope, receipt, opts);
566
+ release();
567
+ return { status: 'persistence-failed', receipt, isIndeterminate: write.isIndeterminate ?? false };
568
+ }
569
+
570
+ const targetRevision = write.newRevision!;
571
+ const hasDiagnostics = preflight.findings.some((f) => f.classification !== 'blocking');
572
+ const receipt = createReceipt({
573
+ operation: OPERATION,
574
+ scope,
575
+ trigger: triggerFor(preflight.canonicalPath),
576
+ expectedStateRevision: preflight.stateRevision,
577
+ targetStateRevision: targetRevision,
578
+ observedStateRevision: targetRevision,
579
+ validationSnapshot: preflight.snapshot.fingerprint,
580
+ summary: hasDiagnostics ? 'Completed with diagnostics' : 'Completed',
581
+ findings: preflight.findings,
582
+ stateChanged: true,
583
+ });
584
+ await appendReceipt(scope, receipt, opts);
585
+ release();
586
+ return { status: 'completed', registration, receipt, newRevision: targetRevision };
587
+ }
588
+
589
+ /** Cancel a preflight without confirming — releases the Attempt Fence (no state mutation). */
590
+ export function cancelLocalRegistration(preflight: LocalRegistrationPreflight): void {
591
+ if (preflight.terminal) return;
592
+ preflight.terminal = true;
593
+ preflight.fence.release();
594
+ }
595
+
596
+ /**
597
+ * Validation Disclosure material for the confirmation surface.
598
+ */
599
+ export function disclosureSummary(preflight: LocalRegistrationPreflight): string {
600
+ const entries = preflight.catalog.entries;
601
+ const available = entries.filter((e) => e.available).length;
602
+ const unavailable = entries.length - available;
603
+ const blockingCount = preflight.findings.filter((f) => f.classification === 'blocking').length;
604
+ const warningCount = preflight.findings.filter((f) => f.classification === 'warning').length;
605
+ const lines = [
606
+ `Scope: ${preflight.scope}`,
607
+ `Source: ${preflight.canonicalPath}`,
608
+ `Marketplace: ${preflight.marketplaceName}`,
609
+ `State Revision: ${preflight.stateRevision}`,
610
+ `Validation Snapshot: ${preflight.snapshot.fingerprint.slice(0, 16)}…`,
611
+ `Entries: ${entries.length} (${available} locatable / ${unavailable} unavailable)`,
612
+ `Findings: ${blockingCount} blocking · ${warningCount} warning`,
613
+ ];
614
+ for (const entry of entries) {
615
+ const status = entry.available ? 'locatable' : `unavailable (${entry.unavailableReason ?? '?'})`;
616
+ lines.push(` ${entry.entryId} ${entry.name ? `· ${entry.name} ` : ''}— ${status}`);
617
+ }
618
+ return lines.join('\n');
619
+ }