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,654 @@
1
+ /**
2
+ * Git Marketplace Registration flow — the Lifecycle Operation seam for #18.
3
+ * See CONTEXT.md: Marketplace Registration, Canonical Git Locator, Git Selector, Resolved Revision,
4
+ * Source Acquisition, Acquisition Trust Base, Source Key (git), Validation Snapshot.
5
+ *
6
+ * Flow: preflight (fence → locator/selector normalization → Source Key → duplicate → acquisition
7
+ * → snapshot + catalog/validation → disclosure) → Registration Confirmation
8
+ * (snapshot + State Revision bound, Default No) → scope-atomic commit → Attempt Receipt.
9
+ */
10
+
11
+ import { readFileSync, statSync, realpathSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ import { commitBridgeState, readBridgeState } from '../bridge-state/store.js';
15
+ import type { Registration, Scope } from '../bridge-state/types.js';
16
+ import { BUDGET } from './budget.js';
17
+ import { CODE, RULE, blocking, hasBlocking, sortFindings, type ValidationFinding } from './findings.js';
18
+ import { parseCatalog, type Catalog } from './catalog.js';
19
+ import { resolveContained } from './contained.js';
20
+ import { acquireAttemptFence, type AttemptFenceHandle } from './fence.js';
21
+ import { createReceipt, type AttemptReceipt } from './receipt.js';
22
+ import { appendReceipt } from '../journal/journal.js';
23
+ import { allocateRegistrationId, deriveInitialAlias, findDuplicateRegistration } from './registration.js';
24
+ import { buildGitSnapshot, type ValidationSnapshot } from './snapshot.js';
25
+ import { gitSourceKey, type SourceKey } from './source-key.js';
26
+ import { normalizeGitLocator, type CanonicalGitLocator } from './git-locator.js';
27
+ import { normalizeGitSelector, parseGitSelectorString, type GitSelectorInput, type NormalizedGitSelector } from './git-selector.js';
28
+ import { acquireGitSource, cleanupAcquisition, type GitExecutor, type AcquisitionTrustOptions } from './git-acquisition.js';
29
+ import { SourceCache } from '../cache/source-cache.js';
30
+ import { MARKETPLACE_CATALOG_RELPATH } from './flow.js';
31
+
32
+ export interface GitRegistrationFlowOptions {
33
+ cwd?: string;
34
+ agentDir?: string;
35
+ projectTrusted?: boolean;
36
+ preallocatedId?: string;
37
+ fenceTimeoutMs?: number;
38
+ /** Injected executor for tests (mocks git) */
39
+ executor?: GitExecutor;
40
+ /** Trust base options */
41
+ trust?: AcquisitionTrustOptions;
42
+ /** Injected Source Cache (defaults to a cache under the given agentDir). */
43
+ cache?: SourceCache;
44
+ /** Destination dir for acquisition (tests) — if not provided, temp is created */
45
+ destDir?: string;
46
+ }
47
+
48
+ export interface GitRegistrationPreflight {
49
+ scope: Scope;
50
+ registrationId: string;
51
+ alias?: string;
52
+ sourceKey: SourceKey;
53
+ locator: CanonicalGitLocator;
54
+ selector: NormalizedGitSelector;
55
+ resolvedRevision: string;
56
+ marketplaceName: string;
57
+ catalog: Catalog;
58
+ snapshot: ValidationSnapshot;
59
+ findings: ValidationFinding[];
60
+ blocked: boolean;
61
+ stateRevision: string;
62
+ fence: AttemptFenceHandle;
63
+ terminal: boolean;
64
+ /** Temp acquisition path (cleaned on cancel/confirm, not persisted) */
65
+ acquiredPath?: string;
66
+ createdTemp?: boolean;
67
+ }
68
+
69
+ export type GitRegistrationOutcome =
70
+ | { status: 'completed'; registration: Registration; receipt: AttemptReceipt; newRevision: string }
71
+ | { status: 'declined'; receipt: AttemptReceipt }
72
+ | { status: 'blocked'; findings: ValidationFinding[]; receipt: AttemptReceipt; existing?: Registration }
73
+ | { status: 'rejected-as-stale'; receipt: AttemptReceipt }
74
+ | { status: 'persistence-failed'; receipt: AttemptReceipt; isIndeterminate: boolean };
75
+
76
+ export type GitPreflightResult =
77
+ | { ok: true; preflight: GitRegistrationPreflight }
78
+ | { ok: false; outcome: GitRegistrationOutcome };
79
+
80
+ const OPERATION = 'Marketplace Registration';
81
+
82
+ function triggerFor(locator: string, selector: string): string {
83
+ return `register git ${locator}#${selector}`;
84
+ }
85
+
86
+ async function blockedResult(
87
+ scope: Scope,
88
+ locatorInput: string,
89
+ selectorInput: string,
90
+ expectedRevision: string,
91
+ findings: ValidationFinding[],
92
+ handle: AttemptFenceHandle | null,
93
+ opts: GitRegistrationFlowOptions = {},
94
+ existing?: Registration,
95
+ ): Promise<{ ok: false; outcome: GitRegistrationOutcome }> {
96
+ if (handle) handle.release();
97
+ const receipt = createReceipt({
98
+ operation: OPERATION,
99
+ scope,
100
+ trigger: triggerFor(locatorInput, selectorInput),
101
+ expectedStateRevision: expectedRevision,
102
+ summary: 'Blocked',
103
+ findings,
104
+ });
105
+ await appendReceipt(scope, receipt, opts);
106
+ return { ok: false, outcome: { status: 'blocked', findings, receipt, existing } };
107
+ }
108
+
109
+ async function readScopeState(scope: Scope, opts: GitRegistrationFlowOptions) {
110
+ return readBridgeState(scope, { cwd: opts.cwd, agentDir: opts.agentDir });
111
+ }
112
+
113
+ function selectorToString(input: GitSelectorInput | string): string {
114
+ if (typeof input === 'string') return input;
115
+ return `${input.kind}:${input.value ?? ''}`;
116
+ }
117
+
118
+ /**
119
+ * Run preflight for a Git Marketplace Source registration.
120
+ */
121
+ export async function preflightGitRegistration(
122
+ scope: Scope,
123
+ locatorInput: string,
124
+ selectorInput: GitSelectorInput | string,
125
+ opts: GitRegistrationFlowOptions = {},
126
+ ): Promise<GitPreflightResult> {
127
+ const read = await readScopeState(scope, opts);
128
+ let expectedRevision = '0';
129
+ let registrations: Registration[] = [];
130
+ if (read.status === 'missing') {
131
+ expectedRevision = read.state!.stateRevision;
132
+ } else if (read.status === 'ok') {
133
+ expectedRevision = read.state!.stateRevision;
134
+ registrations = read.state!.registrations;
135
+ } else {
136
+ const receipt = createReceipt({
137
+ operation: OPERATION,
138
+ scope,
139
+ trigger: triggerFor(String(locatorInput), selectorToString(selectorInput)),
140
+ expectedStateRevision: '?',
141
+ summary: 'Persistence Indeterminate',
142
+ findings: [
143
+ blocking({
144
+ code: CODE.PERSISTENCE_INDETERMINATE,
145
+ phase: 'persistence',
146
+ target: 'registration',
147
+ scope,
148
+ pointer: '',
149
+ rule: 'PERSIST-01',
150
+ outcome: read.error ?? `state is ${read.status}; neither previous nor target verifiable`,
151
+ }),
152
+ ],
153
+ });
154
+ await appendReceipt(scope, receipt, opts);
155
+ return { ok: false, outcome: { status: 'persistence-failed', receipt, isIndeterminate: true } };
156
+ }
157
+
158
+ if (scope === 'project' && opts.projectTrusted !== true) {
159
+ const finding = blocking({
160
+ code: CODE.PROJECT_TRUST_DENIED,
161
+ phase: 'admission',
162
+ target: 'registration',
163
+ scope,
164
+ pointer: '',
165
+ rule: RULE.PROJECT_TRUST_DENIED,
166
+ outcome:
167
+ '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',
168
+ });
169
+ const selStr = selectorToString(selectorInput);
170
+ return blockedResult(scope, locatorInput, selStr, expectedRevision, [finding], null, opts);
171
+ }
172
+
173
+ const fence = await acquireAttemptFence(scope, {
174
+ cwd: opts.cwd,
175
+ agentDir: opts.agentDir,
176
+ fenceTimeoutMs: opts.fenceTimeoutMs,
177
+ projectTrusted: opts.projectTrusted,
178
+ });
179
+ if (!fence.ok) {
180
+ const selStr = selectorToString(selectorInput);
181
+ return blockedResult(scope, locatorInput, selStr, expectedRevision, [fence.finding!], null, opts);
182
+ }
183
+ const handle = fence.handle!;
184
+
185
+ let acquiredPath: string | undefined;
186
+ let createdTemp = false;
187
+
188
+ try {
189
+ const registrationId = opts.preallocatedId ?? allocateRegistrationId();
190
+
191
+ // Normalize locator
192
+ const locRes = normalizeGitLocator(locatorInput, scope);
193
+ if (!locRes.ok) {
194
+ const selStr = selectorToString(selectorInput);
195
+ return blockedResult(scope, locatorInput, selStr, expectedRevision, locRes.findings, handle, opts);
196
+ }
197
+ const locator = locRes.locator!;
198
+
199
+ // Normalize selector
200
+ let selRes;
201
+ if (typeof selectorInput === 'string') {
202
+ selRes = parseGitSelectorString(selectorInput, scope);
203
+ } else {
204
+ selRes = normalizeGitSelector(selectorInput as GitSelectorInput, scope);
205
+ }
206
+ if (!selRes.ok) {
207
+ return blockedResult(
208
+ scope,
209
+ locatorInput,
210
+ selectorToString(selectorInput),
211
+ expectedRevision,
212
+ selRes.findings,
213
+ handle,
214
+ opts,
215
+ );
216
+ }
217
+ const selector = selRes.selector!;
218
+ const selCanonical = selector.canonical;
219
+
220
+ // Git Source Key (canonical URL + exact selector), distinct from local
221
+ const sourceKey = gitSourceKey(locator, selector);
222
+
223
+ // Duplicate check (same scope, same kind+key)
224
+ const dup = findDuplicateRegistration(scope, sourceKey, registrations);
225
+ if (dup.duplicate) {
226
+ return blockedResult(scope, locatorInput, selCanonical, expectedRevision, [dup.finding!], handle, opts, dup.existing);
227
+ }
228
+
229
+ // Non-executing Source Acquisition (clone --no-checkout, no hooks/filters/submodules)
230
+ const acq = await acquireGitSource({
231
+ scope,
232
+ locator,
233
+ selector,
234
+ trust: opts.trust,
235
+ executor: opts.executor,
236
+ destDir: opts.destDir,
237
+ });
238
+ if (!acq.ok) {
239
+ return blockedResult(scope, locatorInput, selCanonical, expectedRevision, acq.findings, handle, opts);
240
+ }
241
+ acquiredPath = realpathSync.native(acq.acquiredPath!);
242
+ createdTemp = acq.createdTemp ?? false;
243
+ const resolvedRevision = acq.resolvedRevision!;
244
+
245
+ // Attach resolved to sourceKey for provenance
246
+ (sourceKey as SourceKey).resolvedRevision = resolvedRevision;
247
+ (sourceKey as SourceKey).canonicalUrl = locator.canonicalUrl;
248
+ (sourceKey as SourceKey).selector = selCanonical;
249
+
250
+ // Catalog / containment / budget
251
+ const findings: ValidationFinding[] = [];
252
+ const catalogPath = join(acquiredPath, MARKETPLACE_CATALOG_RELPATH);
253
+ let catalogBytes = 0;
254
+ try {
255
+ catalogBytes = statSync(catalogPath).size;
256
+ } catch {
257
+ const finding = blocking({
258
+ code: CODE.CATALOG_MISSING,
259
+ phase: 'validation',
260
+ target: 'catalog',
261
+ scope,
262
+ pointer: MARKETPLACE_CATALOG_RELPATH,
263
+ rule: RULE.CATALOG_MISSING,
264
+ outcome: `Marketplace Catalog not found at ${MARKETPLACE_CATALOG_RELPATH}; legacy marketplace shapes do not participate in Bridge ingestion`,
265
+ });
266
+ cleanupAcquisition(acquiredPath);
267
+ return blockedResult(scope, locatorInput, selCanonical, expectedRevision, [finding], handle, opts);
268
+ }
269
+ if (catalogBytes > BUDGET.maxCatalogBytes) {
270
+ const finding = blocking({
271
+ code: CODE.BUDGET_EXCEEDED,
272
+ phase: 'validation',
273
+ target: 'catalog',
274
+ scope,
275
+ pointer: MARKETPLACE_CATALOG_RELPATH,
276
+ rule: RULE.BUDGET_EXCEEDED,
277
+ outcome: `Validation Budget exceeded: catalog ${catalogBytes} bytes > ${BUDGET.maxCatalogBytes}`,
278
+ });
279
+ cleanupAcquisition(acquiredPath);
280
+ return blockedResult(scope, locatorInput, selCanonical, expectedRevision, [finding], handle, opts);
281
+ }
282
+
283
+ let parsed: unknown;
284
+ try {
285
+ parsed = JSON.parse(readFileSync(catalogPath, 'utf-8'));
286
+ } catch (e) {
287
+ const msg = e instanceof Error ? e.message : String(e);
288
+ const finding = blocking({
289
+ code: CODE.CATALOG_MALFORMED,
290
+ phase: 'validation',
291
+ target: 'catalog',
292
+ scope,
293
+ pointer: MARKETPLACE_CATALOG_RELPATH,
294
+ rule: RULE.CATALOG_MALFORMED,
295
+ outcome: `unable to parse marketplace.json: ${msg}`,
296
+ });
297
+ cleanupAcquisition(acquiredPath);
298
+ return blockedResult(scope, locatorInput, selCanonical, expectedRevision, [finding], handle, opts);
299
+ }
300
+
301
+ const catalogResult = parseCatalog(parsed, { scope });
302
+ findings.push(...catalogResult.findings);
303
+ if (!catalogResult.ok) {
304
+ cleanupAcquisition(acquiredPath);
305
+ return blockedResult(scope, locatorInput, selCanonical, expectedRevision, sortFindings(findings), handle, opts);
306
+ }
307
+ const catalog = catalogResult.catalog!;
308
+
309
+ if (catalog.name.length > BUDGET.maxNameLength) {
310
+ findings.push(
311
+ blocking({
312
+ code: CODE.BUDGET_EXCEEDED,
313
+ phase: 'validation',
314
+ target: 'catalog',
315
+ scope,
316
+ pointer: '/name',
317
+ rule: RULE.BUDGET_EXCEEDED,
318
+ outcome: `Validation Budget exceeded: marketplace name ${catalog.name.length} chars > ${BUDGET.maxNameLength}`,
319
+ }),
320
+ );
321
+ }
322
+ if (catalog.entries.length > BUDGET.maxEntries) {
323
+ findings.push(
324
+ blocking({
325
+ code: CODE.BUDGET_EXCEEDED,
326
+ phase: 'validation',
327
+ target: 'catalog',
328
+ scope,
329
+ pointer: '/plugins',
330
+ rule: RULE.BUDGET_EXCEEDED,
331
+ outcome: `Validation Budget exceeded: ${catalog.entries.length} entries > ${BUDGET.maxEntries}`,
332
+ }),
333
+ );
334
+ }
335
+
336
+ for (const entry of catalog.entries) {
337
+ if (entry.type !== 'local' || !entry.path) continue;
338
+ const res = resolveContained(acquiredPath, entry.path, 'any');
339
+ if (res.outcome.kind === 'blocking') {
340
+ const reason = res.outcome.reason;
341
+ const symlink = res.outcome.blockClass === 'symlink';
342
+ findings.push(
343
+ blocking({
344
+ code: symlink ? CODE.CONTAINED_SYMLINK_VIOLATION : CODE.PATH_CONTAINMENT_VIOLATION,
345
+ phase: 'validation',
346
+ target: 'entry',
347
+ scope,
348
+ pointer: `${entry.entryId}/path`,
349
+ rule: symlink ? RULE.CONTAINED_SYMLINK_VIOLATION : RULE.PATH_CONTAINMENT_VIOLATION,
350
+ outcome: `${entry.entryId}: ${reason}`,
351
+ }),
352
+ );
353
+ entry.available = false;
354
+ entry.unavailableReason = reason;
355
+ } else if (res.outcome.kind === 'missing') {
356
+ entry.available = false;
357
+ entry.unavailableReason = 'cannot resolve to a Plugin: target does not exist';
358
+ }
359
+ }
360
+
361
+ const snapshotResult = buildGitSnapshot(acquiredPath, sourceKey, scope, {
362
+ canonicalLocator: locator.canonicalUrl,
363
+ resolvedRevision,
364
+ selectorCanonical: selCanonical,
365
+ });
366
+ findings.push(...snapshotResult.findings);
367
+
368
+ const sorted = sortFindings(findings);
369
+ if (hasBlocking(sorted)) {
370
+ cleanupAcquisition(acquiredPath);
371
+ return blockedResult(scope, locatorInput, selCanonical, expectedRevision, sorted, handle, opts);
372
+ }
373
+
374
+ const alias = deriveInitialAlias(
375
+ catalog.name,
376
+ registrations.map((r) => r.alias).filter((a): a is string => Boolean(a)),
377
+ );
378
+
379
+ // Cache the validated tree under its fingerprint and record the locator+selector index
380
+ // entry for future exact-fingerprint hits (#22).
381
+ const cache = opts.cache ?? new SourceCache({ agentDir: opts.agentDir });
382
+ try {
383
+ await cache.storeTree(acquiredPath, snapshotResult.snapshot!.fingerprint);
384
+ cache.recordIndex({
385
+ fingerprint: snapshotResult.snapshot!.fingerprint,
386
+ resolvedRevision,
387
+ canonicalLocator: locator.canonicalUrl,
388
+ selectorCanonical: selCanonical,
389
+ });
390
+ } catch {
391
+ // Cache is non-authoritative: store failures never block registration.
392
+ }
393
+
394
+ // Keep acquiredPath for potential cleanup on cancel/confirm; snapshot already captured
395
+ const preflight: GitRegistrationPreflight = {
396
+ scope,
397
+ registrationId,
398
+ alias,
399
+ sourceKey,
400
+ locator,
401
+ selector,
402
+ resolvedRevision,
403
+ marketplaceName: catalog.name,
404
+ catalog,
405
+ snapshot: snapshotResult.snapshot!,
406
+ findings: sorted,
407
+ blocked: false,
408
+ stateRevision: expectedRevision,
409
+ fence: handle,
410
+ terminal: false,
411
+ acquiredPath,
412
+ createdTemp,
413
+ };
414
+ return { ok: true, preflight };
415
+ } catch (e) {
416
+ if (acquiredPath) cleanupAcquisition(acquiredPath);
417
+ handle.release();
418
+ const msg = e instanceof Error ? e.message : String(e);
419
+ const finding = blocking({
420
+ code: CODE.GIT_ACQUISITION_FAILED,
421
+ phase: 'validation',
422
+ target: 'registration',
423
+ scope,
424
+ pointer: '',
425
+ rule: RULE.GIT_ACQUISITION_FAILED,
426
+ outcome: `preflight failed: ${msg}`,
427
+ });
428
+ const selStr = selectorToString(selectorInput);
429
+ return blockedResult(scope, locatorInput, selStr, expectedRevision, [finding], null, opts);
430
+ }
431
+ }
432
+
433
+ function snapshotBinds(snapshot: ValidationSnapshot) {
434
+ return { profile: snapshot.profile, ruleset: snapshot.ruleset, budget: snapshot.budget };
435
+ }
436
+
437
+ export async function confirmGitRegistration(
438
+ preflight: GitRegistrationPreflight,
439
+ yes: boolean,
440
+ opts: GitRegistrationFlowOptions = {},
441
+ ): Promise<GitRegistrationOutcome> {
442
+ if (preflight.terminal) {
443
+ const receipt = createReceipt({
444
+ operation: OPERATION,
445
+ scope: preflight.scope,
446
+ trigger: triggerFor(preflight.locator.canonicalUrl, preflight.selector.canonical),
447
+ expectedStateRevision: preflight.stateRevision,
448
+ validationSnapshot: preflight.snapshot.fingerprint,
449
+ summary: 'Blocked',
450
+ findings: [
451
+ blocking({
452
+ code: CODE.ATTEMPT_IN_PROGRESS,
453
+ phase: 'admission',
454
+ target: 'attempt',
455
+ scope: preflight.scope,
456
+ pointer: '',
457
+ rule: RULE.ATTEMPT_IN_PROGRESS,
458
+ outcome: 'attempt already reached a terminal outcome',
459
+ }),
460
+ ],
461
+ });
462
+ await appendReceipt(preflight.scope, receipt, opts);
463
+ return { status: 'blocked', findings: [], receipt };
464
+ }
465
+ preflight.terminal = true;
466
+ const { fence, scope } = preflight;
467
+ const release = () => {
468
+ fence.release();
469
+ if (preflight.acquiredPath) cleanupAcquisition(preflight.acquiredPath);
470
+ };
471
+
472
+ if (!yes) {
473
+ const receipt = createReceipt({
474
+ operation: OPERATION,
475
+ scope,
476
+ trigger: triggerFor(preflight.locator.canonicalUrl, preflight.selector.canonical),
477
+ expectedStateRevision: preflight.stateRevision,
478
+ validationSnapshot: preflight.snapshot.fingerprint,
479
+ summary: 'Declined',
480
+ findings: preflight.findings,
481
+ stateChanged: false,
482
+ });
483
+ await appendReceipt(scope, receipt, opts);
484
+ release();
485
+ return { status: 'declined', receipt };
486
+ }
487
+
488
+ const fresh = await readScopeState(scope, opts);
489
+ if (fresh.status !== 'ok' && fresh.status !== 'missing') {
490
+ const receipt = createReceipt({
491
+ operation: OPERATION,
492
+ scope,
493
+ trigger: triggerFor(preflight.locator.canonicalUrl, preflight.selector.canonical),
494
+ expectedStateRevision: preflight.stateRevision,
495
+ validationSnapshot: preflight.snapshot.fingerprint,
496
+ summary: 'Persistence Indeterminate',
497
+ findings: [
498
+ blocking({
499
+ code: CODE.PERSISTENCE_INDETERMINATE,
500
+ phase: 'persistence',
501
+ target: 'registration',
502
+ scope,
503
+ pointer: '',
504
+ rule: 'PERSIST-01',
505
+ outcome: fresh.error ?? `state is ${fresh.status}; neither previous nor target verifiable`,
506
+ }),
507
+ ],
508
+ });
509
+ await appendReceipt(scope, receipt, opts);
510
+ release();
511
+ return { status: 'persistence-failed', receipt, isIndeterminate: true };
512
+ }
513
+ const currentRevision = fresh.state!.stateRevision;
514
+ if (currentRevision !== preflight.stateRevision) {
515
+ const receipt = createReceipt({
516
+ operation: OPERATION,
517
+ scope,
518
+ trigger: triggerFor(preflight.locator.canonicalUrl, preflight.selector.canonical),
519
+ expectedStateRevision: preflight.stateRevision,
520
+ targetStateRevision: currentRevision,
521
+ validationSnapshot: preflight.snapshot.fingerprint,
522
+ summary: 'Rejected as Stale',
523
+ findings: [
524
+ blocking({
525
+ code: CODE.REJECTED_AS_STALE,
526
+ phase: 'persistence',
527
+ target: 'attempt',
528
+ scope,
529
+ pointer: '',
530
+ rule: RULE.REJECTED_AS_STALE,
531
+ outcome: `State Revision changed (${preflight.stateRevision} → ${currentRevision}); re-run preflight and confirmation — no automatic merge`,
532
+ }),
533
+ ],
534
+ stateChanged: false,
535
+ });
536
+ await appendReceipt(scope, receipt, opts);
537
+ release();
538
+ return { status: 'rejected-as-stale', receipt };
539
+ }
540
+
541
+ const dup = findDuplicateRegistration(scope, preflight.sourceKey, fresh.state!.registrations);
542
+ if (dup.duplicate) {
543
+ const receipt = createReceipt({
544
+ operation: OPERATION,
545
+ scope,
546
+ trigger: triggerFor(preflight.locator.canonicalUrl, preflight.selector.canonical),
547
+ expectedStateRevision: preflight.stateRevision,
548
+ validationSnapshot: preflight.snapshot.fingerprint,
549
+ summary: 'Blocked',
550
+ findings: [dup.finding!],
551
+ });
552
+ await appendReceipt(scope, receipt, opts);
553
+ release();
554
+ return { status: 'blocked', findings: [dup.finding!], receipt, existing: dup.existing };
555
+ }
556
+
557
+ const registration: Registration = {
558
+ id: preflight.registrationId,
559
+ alias: preflight.alias,
560
+ marketplaceName: preflight.marketplaceName,
561
+ sourceKind: 'git',
562
+ source: preflight.locator.canonicalUrl,
563
+ sourceKey: preflight.sourceKey,
564
+ canonicalLocator: preflight.locator.canonicalUrl,
565
+ gitSelector: { kind: preflight.selector.kind, canonical: preflight.selector.canonical, raw: preflight.selector.raw },
566
+ resolvedRevision: preflight.resolvedRevision,
567
+ validationSnapshot: preflight.snapshot.fingerprint,
568
+ snapshotBinds: snapshotBinds(preflight.snapshot),
569
+ };
570
+
571
+ const write = await commitBridgeState(
572
+ scope,
573
+ (current) => ({ ...current, registrations: [...current.registrations, registration] }),
574
+ { cwd: opts.cwd, agentDir: opts.agentDir, lockTimeoutMs: opts.fenceTimeoutMs ?? 5000 },
575
+ );
576
+ if (!write.success) {
577
+ const summary: 'Persistence Failed' | 'Persistence Indeterminate' = write.isIndeterminate
578
+ ? 'Persistence Indeterminate'
579
+ : 'Persistence Failed';
580
+ const receipt = createReceipt({
581
+ operation: OPERATION,
582
+ scope,
583
+ trigger: triggerFor(preflight.locator.canonicalUrl, preflight.selector.canonical),
584
+ expectedStateRevision: preflight.stateRevision,
585
+ targetStateRevision: '?',
586
+ validationSnapshot: preflight.snapshot.fingerprint,
587
+ summary,
588
+ findings: [
589
+ blocking({
590
+ code: write.isIndeterminate ? CODE.PERSISTENCE_INDETERMINATE : CODE.PERSISTENCE_FAILED,
591
+ phase: 'persistence',
592
+ target: 'registration',
593
+ scope,
594
+ pointer: '',
595
+ rule: write.isIndeterminate ? 'PERSIST-01' : 'PERSIST-02',
596
+ outcome: write.error ?? summary,
597
+ }),
598
+ ],
599
+ stateChanged: false,
600
+ });
601
+ await appendReceipt(scope, receipt, opts);
602
+ release();
603
+ return { status: 'persistence-failed', receipt, isIndeterminate: write.isIndeterminate ?? false };
604
+ }
605
+
606
+ const targetRevision = write.newRevision!;
607
+ const hasDiagnostics = preflight.findings.some((f) => f.classification !== 'blocking');
608
+ const receipt = createReceipt({
609
+ operation: OPERATION,
610
+ scope,
611
+ trigger: triggerFor(preflight.locator.canonicalUrl, preflight.selector.canonical),
612
+ expectedStateRevision: preflight.stateRevision,
613
+ targetStateRevision: targetRevision,
614
+ observedStateRevision: targetRevision,
615
+ validationSnapshot: preflight.snapshot.fingerprint,
616
+ summary: hasDiagnostics ? 'Completed with diagnostics' : 'Completed',
617
+ findings: preflight.findings,
618
+ stateChanged: true,
619
+ });
620
+ await appendReceipt(scope, receipt, opts);
621
+ release();
622
+ return { status: 'completed', registration, receipt, newRevision: targetRevision };
623
+ }
624
+
625
+ export function cancelGitRegistration(preflight: GitRegistrationPreflight): void {
626
+ if (preflight.terminal) return;
627
+ preflight.terminal = true;
628
+ preflight.fence.release();
629
+ if (preflight.acquiredPath) cleanupAcquisition(preflight.acquiredPath);
630
+ }
631
+
632
+ export function disclosureSummaryGit(preflight: GitRegistrationPreflight): string {
633
+ const entries = preflight.catalog.entries;
634
+ const available = entries.filter((e) => e.available).length;
635
+ const unavailable = entries.length - available;
636
+ const blockingCount = preflight.findings.filter((f) => f.classification === 'blocking').length;
637
+ const warningCount = preflight.findings.filter((f) => f.classification === 'warning').length;
638
+ const lines = [
639
+ `Scope: ${preflight.scope}`,
640
+ `Canonical Locator: ${preflight.locator.canonicalUrl} (${preflight.locator.transport} · ${preflight.locator.host}${preflight.locator.port ? `:${preflight.locator.port}` : ''}${preflight.locator.path}${preflight.locator.user ? ` · user=${preflight.locator.user}` : ''})`,
641
+ `Git Selector: ${preflight.selector.kind} → ${preflight.selector.canonical}`,
642
+ `Resolved Revision: ${preflight.resolvedRevision}`,
643
+ `Marketplace: ${preflight.marketplaceName}`,
644
+ `State Revision: ${preflight.stateRevision}`,
645
+ `Validation Snapshot: ${preflight.snapshot.fingerprint.slice(0, 16)}…`,
646
+ `Entries: ${entries.length} (${available} locatable / ${unavailable} unavailable)`,
647
+ `Findings: ${blockingCount} blocking · ${warningCount} warning`,
648
+ ];
649
+ for (const entry of entries) {
650
+ const status = entry.available ? 'locatable' : `unavailable (${entry.unavailableReason ?? '?'})`;
651
+ lines.push(` ${entry.entryId} ${entry.name ? `· ${entry.name} ` : ''}— ${status}`);
652
+ }
653
+ return lines.join('\n');
654
+ }