amalgm 0.1.245 → 0.1.246

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 (157) hide show
  1. package/README.md +12 -0
  2. package/lib/cli.js +146 -8
  3. package/lib/layout.js +89 -4
  4. package/lib/shared-realtime-tunnel.js +384 -43
  5. package/lib/supervisor.js +0 -3
  6. package/package.json +2 -2
  7. package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
  8. package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
  9. package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
  10. package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
  11. package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
  12. package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
  13. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
  14. package/runtime/scripts/amalgm-mcp/index.js +4 -0
  15. package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
  16. package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
  17. package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
  18. package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
  19. package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
  20. package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
  21. package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
  22. package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
  23. package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
  24. package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
  25. package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
  26. package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
  27. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
  28. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
  29. package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
  30. package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
  31. package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
  32. package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
  33. package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
  34. package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
  35. package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
  36. package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
  37. package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
  38. package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
  39. package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
  40. package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
  41. package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
  42. package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
  43. package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
  44. package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
  45. package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
  46. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
  47. package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
  48. package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
  49. package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
  50. package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
  51. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
  52. package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
  53. package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
  54. package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
  55. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
  56. package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
  57. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
  58. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
  59. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
  60. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
  61. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
  62. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
  63. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
  64. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
  65. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
  66. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
  67. package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
  68. package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
  69. package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
  70. package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
  71. package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
  72. package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
  73. package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
  74. package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
  75. package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
  76. package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
  77. package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
  78. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
  79. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
  80. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
  81. package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
  82. package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
  83. package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
  84. package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
  85. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
  86. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
  87. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
  88. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
  89. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
  90. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
  91. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
  92. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
  93. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
  94. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
  95. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
  96. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
  97. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
  98. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
  99. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
  100. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
  101. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
  102. package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
  103. package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
  104. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
  105. package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
  106. package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
  107. package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
  108. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
  109. package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
  110. package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
  111. package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
  112. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
  113. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
  114. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
  115. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
  116. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
  117. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
  118. package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
  119. package/runtime/scripts/local-gateway.js +1 -0
  120. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
  121. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
  122. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
  123. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
  124. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
  125. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
  126. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
  127. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
  128. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
  129. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
  130. package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
  131. package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
  132. package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
  133. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
  134. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  135. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  136. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
  137. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
  138. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
  139. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
  140. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
  141. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
  142. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
  143. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
  144. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
  145. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
  146. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
  147. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
  148. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
  149. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
  150. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
  151. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
  152. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
  153. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
  154. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
  155. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
  156. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
  157. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
@@ -0,0 +1,752 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The shared registration operation — the `amalgm register` boundary
5
+ * (docs/realtime-rails.md §3.1). One call turns a path into registered,
6
+ * watched ground: inspect the address (a symlink registers AS the link
7
+ * before anything resolves it), enroll exactly that ground with the
8
+ * observer, register it in the ONE identity registry, then follow every
9
+ * symlink edge to a fixpoint so each reachable real target is registered
10
+ * exactly once. The CLI and SDK both call this; neither reimplements it.
11
+ *
12
+ * The model, in five sentences. ONE identity database holds every
13
+ * registered entity on this installation; a TREE is one active
14
+ * parentless record with its descendants beneath it — a sync boundary
15
+ * (its own future head and rail), never an identity boundary. Every
16
+ * top-level enrolled ground — a workspace, a repository, a single file,
17
+ * or an explicitly added link — is such a tree, with one evidence LENS
18
+ * over its observer root (nested repo territories live INSIDE their
19
+ * tree as repo.git records; they are never trees of their own). A
20
+ * TREE'S IDENTITY IS ITS UUID, PERMANENT; its root path is only the
21
+ * current local address — lenses are keyed by the observer's rootId
22
+ * (the witness), the observer rescues moved ground by physical
23
+ * continuity, and an orphaned tree arriving inside another tree is
24
+ * ADOPTED there: one lawful parent update on its parentless record,
25
+ * after which this boundary retires the orphan's observer roots and
26
+ * evidence scope (the ground is covered by its new tree). "Watched"
27
+ * and "registered" are the same act here: an observer root this
28
+ * boundary enrolls always gets its lens and record, so nothing is ever
29
+ * ground without identity. What a pointer REACHES is derived at this
30
+ * layer and only here — resolution needs eyes on every tree, so
31
+ * `linkTargets` lives on the boundary, not inside any single lens.
32
+ *
33
+ * THE LOSS LAW (evidence.js assertWitnessed): every active parentless record must
34
+ * be claimed by a LIVE observer root's witness binding. A lost or torn
35
+ * observer store leaves registered trees unclaimed, and this boundary
36
+ * convicts that loudly before any lens reconciles — a lost store is
37
+ * never a fresh start, and no replacement identities mint over
38
+ * surviving records. Re-enrollment after loss is an explicit act.
39
+ *
40
+ * Registration is add-only and policy-gated: following edges and adding
41
+ * ground never remove coverage; amalgm's enrollment policy is asked on
42
+ * EVERY route (an edge is refused and reported, an explicit add refuses
43
+ * loudly — the deliberate override is future work); and a target ground
44
+ * that would swallow an enrolled root is refused (`reported()`) — one
45
+ * owner per path. Removing coverage is a future explicit operation
46
+ * (`amalgm remove`), never a side effect. (Retiring an adopted tree's
47
+ * observer roots is not removal: the same ground stays covered, by its
48
+ * new tree.)
49
+ *
50
+ * Storage policy is injected (the Axiom-2 pattern): `openIdentity()` is
51
+ * called ONCE and returns `{ db, sentinelPath }` for the durable
52
+ * installation registry or `{ db, ephemeral: true }` for a real
53
+ * in-memory one. One database, one sentinel, one epoch — identity is
54
+ * never partitioned by tree, which is exactly what makes a cross-tree
55
+ * move one transaction instead of an export between databases.
56
+ */
57
+
58
+ const fs = require('fs');
59
+ const path = require('path');
60
+
61
+ const { followEdges } = require('../observer/edges');
62
+ const { createRegistry } = require('../registry');
63
+ const { assertWitnessed, createEvidence, createEvidenceMemory } = require('../registry/evidence');
64
+ const { capture } = require('../repocard');
65
+ const { refusal } = require('./refusal');
66
+ const { createRepoFollowers } = require('./repo-followers');
67
+ const { createRepoStates } = require('./repo-states');
68
+ const { registerTree } = require('./tree');
69
+
70
+ // Events that can change what the recorded edges reach: a new or
71
+ // retargeted pointer, repo ground moving under its card (a worktree
72
+ // change may be a symlink change — the census reads fresh), or a whole
73
+ // root at a new address (its relative out-edges resolve from there now).
74
+ const EDGE_EVENTS = new Set(['link.created', 'link.changed', 'repo.changed', 'repo.discovered', 'root.moved']);
75
+
76
+ function createRegistration(options) {
77
+ const { observer, openIdentity, classify, emit = () => {} } = options;
78
+ if (!observer) throw new Error('createRegistration requires { observer }');
79
+ if (typeof openIdentity !== 'function') {
80
+ throw new Error('createRegistration requires { openIdentity }: () => ({ db, sentinelPath }) or ({ db, ephemeral: true }) — ONE identity database holds every registered tree, opened once');
81
+ }
82
+ if (typeof classify !== 'function') {
83
+ throw new Error('createRegistration requires { classify }: ({ binary }) => file type — an injected policy over the OBSERVED BYTES\' own report, never a built-in rule and never a fresh read of the ground');
84
+ }
85
+
86
+ const opened = openIdentity();
87
+ const registry = createRegistry({
88
+ db: opened.db,
89
+ sentinelPath: opened.sentinelPath,
90
+ ephemeral: opened.ephemeral,
91
+ emit,
92
+ journal: opened.journal ?? null,
93
+ });
94
+ const memory = createEvidenceMemory(opened.db);
95
+ const repoStates = createRepoStates(opened.db);
96
+ const lenses = new Map(); // rootId (permanent witness) -> evidence lens
97
+ let reported = []; // the latest edge pass's refusals
98
+ let edgesDirty = false;
99
+ let scheduled = false;
100
+ let stopped = false;
101
+ const repoFollowers = createRepoFollowers({
102
+ observer,
103
+ registry,
104
+ repoStates,
105
+ recordForRoot: repoRecordForRoot,
106
+ onSettled: reconcileSettledRepo,
107
+ });
108
+
109
+ /** Tree roots are the non-enclosed observer roots: a nested repo
110
+ * territory belongs to the tree that encloses it. */
111
+ function treeRoots() {
112
+ const roots = observer.listRoots();
113
+ return roots.filter((root) => !roots.some((other) =>
114
+ other.rootId !== root.rootId && root.path.startsWith(`${other.path}/`)));
115
+ }
116
+
117
+ function treeRootCovering(absPath) {
118
+ return treeRoots()
119
+ .filter((root) => absPath === root.path || absPath.startsWith(`${root.path}/`))
120
+ .sort((a, b) => b.path.length - a.path.length)[0] ?? null;
121
+ }
122
+
123
+ /** The tree's top record, found the only honest way: by its witness. */
124
+ function rootRecordOf(rootId) {
125
+ const uuid = memory.boundTo(rootId);
126
+ return uuid === undefined ? null : registry.record(uuid);
127
+ }
128
+
129
+ /** Watched implies registered: every tree root gets its lens, born
130
+ * with one reconciliation (events are hints; birth reads store truth).
131
+ * A lens whose witness record gained a parent was ADOPTED into another
132
+ * tree — its ground is covered there now, so its observer family and
133
+ * evidence scope retire. Idempotent; convicts loss first (the loss
134
+ * law, evidence.js `assertWitnessed`). */
135
+ function ensureTrees() {
136
+ assertWitnessed({ observer, registry, memory });
137
+ // Retirement residue: a scope whose witness root no longer lives is a
138
+ // finished retirement's leftovers. The loss law above already vouched
139
+ // for every living tree — a dead-witness binding on a PARENTLESS
140
+ // record convicted there — so dropping the rest can never erase a
141
+ // claim, and a retirement torn at any point completes right here.
142
+ const liveIds = new Set(observer.listRoots().map((row) => row.rootId));
143
+ for (const { scopeId } of memory.allBindings()) {
144
+ if (!liveIds.has(scopeId)) memory.dropScope(scopeId);
145
+ }
146
+ for (const [rootId] of [...lenses]) {
147
+ const row = observer.listRoots().find((member) => member.rootId === rootId);
148
+ if (!row) {
149
+ lenses.delete(rootId);
150
+ continue;
151
+ }
152
+ const record = rootRecordOf(rootId);
153
+ const nestedRepoScope = row.kind === 'repo' && record?.type === 'repo.git'
154
+ && observer.listRoots().some((other) => other.rootId !== row.rootId
155
+ && row.path.startsWith(`${other.path}/`));
156
+ if (record && record.parentUUID !== null && !nestedRepoScope) {
157
+ // Retirement is EVIDENCE FIRST, IDENTITY LAST: observer rows go
158
+ // before the witness binding (descendants before the root), so a
159
+ // crash anywhere leaves either a living root whose binding still
160
+ // proves the adoption — retried here — or dead-scope residue the
161
+ // sweep above collects. The reverse order once minted a duplicate
162
+ // tree: an unbound living root looks like fresh ground.
163
+ const family = observer.listRoots()
164
+ .filter((member) => member.rootId === rootId || member.path.startsWith(`${row.path}/`));
165
+ for (const member of family) {
166
+ if (member.rootId !== rootId) observer.removeRoot(member.rootId);
167
+ }
168
+ observer.removeRoot(rootId);
169
+ memory.dropScope(rootId);
170
+ lenses.delete(rootId);
171
+ }
172
+ }
173
+ for (const root of treeRoots()) {
174
+ if (lenses.has(root.rootId)) continue;
175
+ const lens = createEvidence({ observer, registry, memory, rootId: root.rootId, classify });
176
+ lenses.set(root.rootId, lens);
177
+ lens.reconcile();
178
+ }
179
+ // A nested repository is one semantic root inside its enclosing tree.
180
+ // It therefore gets its own evidence scope (for its child channels),
181
+ // while retaining its existing parent UUID. The top-level tree lens
182
+ // never walks through this boundary.
183
+ for (const root of observer.listRoots().filter((row) => row.kind === 'repo')) {
184
+ const record = repoRecordForRoot(root);
185
+ if (!record) continue;
186
+ if (memory.boundTo(root.rootId) !== record.uuid) {
187
+ memory.bind(root.rootId, root.rootId, record.uuid);
188
+ }
189
+ if (lenses.has(root.rootId)) continue;
190
+ const lens = createEvidence({ observer, registry, memory, rootId: root.rootId, classify });
191
+ lenses.set(root.rootId, lens);
192
+ lens.reconcile();
193
+ }
194
+ }
195
+
196
+ function follow() {
197
+ edgesDirty = false;
198
+ try {
199
+ const result = followEdges(observer);
200
+ registerFollowedTrees();
201
+ // An unsettled edge surface concludes nothing about completeness:
202
+ // the boundary stays dirty, and the observer's own backed-off
203
+ // rescan of that ground rings `repo.changed` when it settles —
204
+ // which re-marks the edges dirty here and retries the follow.
205
+ if (!result.settled) edgesDirty = true;
206
+ reported = result.reported;
207
+ ensureTrees();
208
+ return result;
209
+ } catch (error) {
210
+ edgesDirty = true;
211
+ throw error;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * `followEdges` is deliberately identity-free: it establishes what real
217
+ * ground is reachable and asks the observer to cover it. Identity must be
218
+ * born here, at the registration boundary, before a lens can reconcile the
219
+ * new root. That makes an edge-reached repository the same thing as a
220
+ * directly added repository: a complete structural tree plus one initial
221
+ * Card + Checkpoint document, all before Watch + Detect can describe it.
222
+ *
223
+ * Only non-enclosed roots are enrolled here. Nested repositories belong to
224
+ * their enclosing structural walk, which creates their `repo.git` records
225
+ * and baselines in the same transaction as their parent tree.
226
+ */
227
+ function registerFollowedTrees() {
228
+ const unregistered = treeRoots()
229
+ .filter((root) => memory.boundTo(root.rootId) === undefined)
230
+ .sort((left, right) => left.path.localeCompare(right.path));
231
+
232
+ for (const root of unregistered) {
233
+ try {
234
+ assertWitnessed({ observer, registry, memory });
235
+ registerTree({
236
+ root,
237
+ registry,
238
+ memory,
239
+ shouldEnroll: observer.shouldEnroll,
240
+ captureRepo: (repoPath) => capture(repoPath, { withBundle: false }),
241
+ repoStates,
242
+ });
243
+ } catch (error) {
244
+ // An observer root is only coverage until its one structural
245
+ // registration transaction commits. Leave no watched-but-unregistered
246
+ // family behind: the still-present link will attempt this same add on
247
+ // the next edge pass, while a successful sibling stays intact.
248
+ const family = observer.listRoots()
249
+ .filter((member) => member.path === root.path || member.path.startsWith(`${root.path}/`))
250
+ .sort((left, right) => right.path.length - left.path.length);
251
+ for (const member of family) observer.removeRoot(member.rootId);
252
+ throw error;
253
+ }
254
+ }
255
+ }
256
+
257
+ function tryFollow() {
258
+ try {
259
+ follow();
260
+ } catch (error) {
261
+ console.warn('[Registration] edge follow failed (stays dirty):', error?.message || error);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * The entity that OWNS a derived address inside one tree, walked slot
267
+ * by slot — '' is the tree's root record. Repository boundaries select
268
+ * Card + Checkpoint realtime semantics, but their allowed descendants
269
+ * still have records; this walk therefore always reaches the exact
270
+ * registered entity at the address.
271
+ */
272
+ function owningRecordAt(rootRecord, relPath) {
273
+ if (!rootRecord) return null;
274
+ if (relPath === '') return rootRecord;
275
+ let cursor = rootRecord.uuid;
276
+ const parts = relPath.split('/');
277
+ for (let i = 0; i < parts.length - 1; i++) {
278
+ const at = registry.activeAt(cursor, parts[i]);
279
+ if (!at) return null;
280
+ cursor = at.uuid;
281
+ }
282
+ return registry.activeAt(cursor, parts[parts.length - 1]);
283
+ }
284
+
285
+ /** The `repo.git` entity an observer repo root represents. A directly
286
+ * registered repo owns itself; a nested repo owns its own boundary
287
+ * record, even though its worktree leaves are structurally registered
288
+ * below that record. */
289
+ function repoRecordForRoot(repoRoot) {
290
+ const home = treeRootCovering(repoRoot.path);
291
+ if (!home) return null;
292
+ const tree = rootRecordOf(home.rootId);
293
+ if (!tree) return null;
294
+ const rel = repoRoot.path === home.path ? '' : repoRoot.path.slice(home.path.length + 1);
295
+ const record = owningRecordAt(tree, rel);
296
+ return record?.type === 'repo.git' ? record : null;
297
+ }
298
+
299
+ /**
300
+ * Card + Checkpoint is the filter for repo children. This is the only
301
+ * place ordinary worktree channels are refreshed for a repo root: raw
302
+ * filesystem doorbells wake the follower, and a settled follower calls
303
+ * here exactly once for the resulting state.
304
+ */
305
+ function reconcileSettledRepo({ root }) {
306
+ observer.reconcileRepoWorktree(root.rootId);
307
+ ensureTrees();
308
+ lenses.get(root.rootId)?.flushIfDirty();
309
+ }
310
+
311
+ /** Establish every repo's initial child-channel baseline after its
312
+ * structural tree and persisted Card + Checkpoint already exist. Nested
313
+ * repos appear while their parent is scanned, so loop to the fixed point. */
314
+ function reconcileInitialRepoWorktrees(rootPath) {
315
+ const seen = new Set();
316
+ while (true) {
317
+ const pending = observer.listRoots().filter((root) => root.kind === 'repo'
318
+ && (root.path === rootPath || root.path.startsWith(`${rootPath}/`))
319
+ && !seen.has(root.rootId));
320
+ if (pending.length === 0) return;
321
+ for (const root of pending) {
322
+ seen.add(root.rootId);
323
+ observer.reconcileRepoWorktree(root.rootId);
324
+ }
325
+ ensureTrees();
326
+ for (const root of pending) lenses.get(root.rootId)?.flushIfDirty();
327
+ }
328
+ }
329
+
330
+ /**
331
+ * One observer event wakes one Record owner. An observer root with its own
332
+ * lens (every repo.git boundary, including a nested one) owns the event it
333
+ * emitted. Only events from ground without a lens fall back to the
334
+ * enclosing tree. Containment and event ownership are different questions:
335
+ * the former finds a parent tree; the latter must preserve the semantic
336
+ * boundary that detected the change.
337
+ */
338
+ function notifyEventOwner(event) {
339
+ const direct = event.rootId ? lenses.get(event.rootId) : null;
340
+ if (direct) {
341
+ direct.onEvent();
342
+ return;
343
+ }
344
+ const row = observer.listRoots().find((root) => root.rootId === event.rootId);
345
+ const home = row ? treeRootCovering(row.path)
346
+ : (typeof event.path === 'string' && path.isAbsolute(event.path) ? treeRootCovering(event.path) : null);
347
+ if (home) lenses.get(home.rootId)?.onEvent();
348
+ else for (const lens of lenses.values()) lens.onEvent();
349
+ }
350
+
351
+ /** `add` may only claim success when every watcher it established is
352
+ * genuinely live. A filesystem watcher that could not open is not a
353
+ * warning; it means the requested Register → Watch operation is incomplete
354
+ * and the caller must hear that. */
355
+ function requireLiveWatchers(rootPath) {
356
+ const belongs = (root) => root.path === rootPath || root.path.startsWith(`${rootPath}/`);
357
+ let unavailable = observer.status().filter((root) => belongs(root)
358
+ && (!root.watching || root.degraded || (root.addressRequired
359
+ && (!root.addressWatching || root.addressDegraded))));
360
+ // A normal successful start is left alone. Only a failed watcher birth
361
+ // is retried, so registration has one deliberate Watch transition.
362
+ for (const failed of unavailable) observer.start(failed.rootId);
363
+ unavailable = observer.status().filter((root) => (root.path === rootPath
364
+ || root.path.startsWith(`${rootPath}/`))
365
+ && (!root.watching || root.degraded || (root.addressRequired
366
+ && (!root.addressWatching || root.addressDegraded))));
367
+ if (unavailable.length > 0) {
368
+ throw new Error(`registered ${rootPath}, but could not start required watcher(s): ${unavailable.map((root) => root.path).join(', ')}`);
369
+ }
370
+ }
371
+
372
+ /** Real ground -> the UUID of the entity that owns it, across trees. */
373
+ function resolveGround(real) {
374
+ const home = treeRootCovering(real);
375
+ if (!home) return null;
376
+ const rel = real === home.path ? '' : real.slice(home.path.length + 1);
377
+ return owningRecordAt(rootRecordOf(home.rootId), rel)?.uuid ?? null;
378
+ }
379
+
380
+ /** The directory an edge's relative target resolves against — a
381
+ * single-ground pointer root's base is its parent directory. */
382
+ function edgeBase(root, relPath) {
383
+ if (root.kind === 'file') return path.dirname(root.path);
384
+ return path.resolve(root.path, path.dirname(relPath));
385
+ }
386
+
387
+ function treeAt(rootPath) {
388
+ const root = observer.listRoots().find((row) => row.path === rootPath);
389
+ const lens = root && lenses.get(root.rootId);
390
+ if (!lens) return null;
391
+ return {
392
+ registry,
393
+ evidence: lens,
394
+ root: () => rootRecordOf(root.rootId),
395
+ };
396
+ }
397
+
398
+ /** An entity's CURRENT local address, derived from its record chain and
399
+ * its tree's observer address — never stored. null when its tree is not
400
+ * locally bound. */
401
+ function addressOf(uuid) {
402
+ const names = [];
403
+ let record = registry.record(uuid);
404
+ while (record.parentUUID !== null) {
405
+ names.unshift(record.name);
406
+ record = registry.record(record.parentUUID);
407
+ }
408
+ const home = treeRoots().find((row) => memory.boundTo(row.rootId) === record.uuid);
409
+ if (!home) return null;
410
+ return names.length === 0 ? home.path : path.join(home.path, ...names);
411
+ }
412
+
413
+ /**
414
+ * A managed reference is CANONICAL REGISTRY TRUTH with an idempotent
415
+ * local projection, and this is the projector: each reference's
416
+ * rendering must be a symlink at the reference's derived address,
417
+ * pointing at its target's current address. A missing rendering is
418
+ * recreated; a drifted one is rewritten. The rewrite is
419
+ * remove-then-render, deliberately not a temp-and-rename: a crash
420
+ * between the two acts leaves a missing rendering — exactly what the
421
+ * next pass recreates — while a stray temp name inside user ground
422
+ * would be scanned as new ground and mint an entity. Convergence is
423
+ * the atomicity. Real ground occupying the slot is NEVER overwritten
424
+ * (the lens reports that divergence); a target with no local address
425
+ * leaves the rendering as it stands — dangling is honest — repaired
426
+ * the moment the target is rescued or reattached. This is the one
427
+ * place the detection side writes disk, and it writes only Amalgm's
428
+ * own artifact — never user data. A write echoes back as a link
429
+ * channel fact; the entity stays silent because a reference's payload
430
+ * is identity, which no ground can attest.
431
+ */
432
+ function maintainReferences() {
433
+ for (const { record, targetUUID } of registry.references()) {
434
+ const rendered = addressOf(record.uuid);
435
+ if (rendered === null) continue; // the reference's own tree is not locally bound
436
+ let target;
437
+ try {
438
+ target = addressOf(targetUUID);
439
+ } catch {
440
+ continue; // the target record is gone from this registry: nothing to aim at
441
+ }
442
+ if (target === null) continue; // target unavailable: dangling (or missing) until rescue repairs it
443
+ let current = null; // null = no rendering holds the slot
444
+ try {
445
+ const stat = fs.lstatSync(rendered);
446
+ if (!stat.isSymbolicLink()) continue; // real ground on the slot: divergence (the lens reports it), never overwritten
447
+ current = fs.readlinkSync(rendered);
448
+ } catch {
449
+ current = null; // missing, or raced away mid-look: recreated below
450
+ }
451
+ if (current === target) continue; // the projection already holds
452
+ if (current !== null) fs.unlinkSync(rendered);
453
+ fs.symlinkSync(target, rendered);
454
+ const home = treeRootCovering(rendered);
455
+ if (home) {
456
+ observer.scanNow(home.rootId);
457
+ // The write's echo may fire where no boundary listens yet (the
458
+ // startup pass runs during construction), so the projector hands
459
+ // the fact over itself: the next pass always binds the
460
+ // rendering's channel.
461
+ lenses.get(home.rootId)?.onEvent();
462
+ }
463
+ }
464
+ }
465
+
466
+ /**
467
+ * `amalgm register <path>` has two deliberately ordered acts:
468
+ *
469
+ * 1. Register the complete entity tree from its structural shape.
470
+ * 2. Start Watch and let Detect describe the ground from then on.
471
+ *
472
+ * The address is inspected first: a symlink at it registers as the link
473
+ * entity itself (its target then follows after detection), and only real
474
+ * ground is resolved. Exactly the named ground enrolls — a file or link
475
+ * as a tree of one, a repo only when the named path IS the repo root.
476
+ * Changes while the registration walk runs are outside this operation's
477
+ * contract; no readiness protocol or user-ground mutation is involved.
478
+ */
479
+ function add(targetPath, options = {}) {
480
+ const identityAt = options.identityAt ?? null;
481
+ if (identityAt !== null && typeof identityAt !== 'function') {
482
+ throw new Error('add identityAt must be a function when supplied');
483
+ }
484
+ let address;
485
+ let ground;
486
+ try {
487
+ const parent = fs.realpathSync(path.dirname(targetPath));
488
+ address = path.join(parent, path.basename(targetPath));
489
+ ground = fs.lstatSync(address).isSymbolicLink() ? address : fs.realpathSync(address);
490
+ } catch (error) {
491
+ // Ground that is not there is a REFUSAL of this request, not a
492
+ // fault: nothing is wrong with the installation, the address is
493
+ // simply not ground. (An address that exists but cannot be read
494
+ // is not this: it falls through as the fault it is.)
495
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
496
+ throw refusal(`refusing to add ${targetPath}: there is no ground at that address`);
497
+ }
498
+ const covering = treeRootCovering(ground);
499
+ if (covering) {
500
+ ensureTrees();
501
+ tryFollow();
502
+ requireLiveWatchers(covering.path);
503
+ return { rootPath: covering.path, record: rootRecordOf(covering.rootId) };
504
+ }
505
+ // The policy judges the COMPLETE address — a benign final name
506
+ // inside excluded ground (.ssh/config) is still excluded.
507
+ if (!observer.shouldEnroll(ground)) {
508
+ throw refusal(`refusing to add ${ground}: amalgm's enrollment policy excludes it — the explicit override is a future deliberate operation, never a default`);
509
+ }
510
+ const swallowed = observer.listRoots().filter((root) => root.path.startsWith(`${ground}/`));
511
+ if (swallowed.length > 0) {
512
+ throw refusal(`refusing to add ${ground}: it contains already-registered ground (${swallowed.map((root) => root.path).join(', ')}) — one owner per path; remove the inner registration first, explicitly`);
513
+ }
514
+ const root = observer.enrollRoot(ground, { scan: false, watch: false });
515
+ try {
516
+ assertWitnessed({ observer, registry, memory });
517
+ registerTree({
518
+ root,
519
+ registry,
520
+ memory,
521
+ shouldEnroll: observer.shouldEnroll,
522
+ captureRepo: (repoPath) => capture(repoPath, { withBundle: false }),
523
+ repoStates,
524
+ identityAt,
525
+ });
526
+ } catch (error) {
527
+ // The observer root is only an address until structural registration
528
+ // commits. Do not leave an unregistered address behind after a refused
529
+ // or failed walk; a committed tree, by contrast, is durable truth and
530
+ // is never rolled back by a later Watch/Detect fault.
531
+ observer.removeRoot(root.rootId);
532
+ throw error;
533
+ }
534
+ // Registration committed the complete structural tree before Watch.
535
+ // Start's first census must run before a parent lens is allowed to
536
+ // conclude that a pre-registered nested repo is absent: unobserved is
537
+ // not absent. Only then do evidence lenses reconcile that census.
538
+ observer.start(root.rootId);
539
+ ensureTrees();
540
+ reconcileInitialRepoWorktrees(root.path);
541
+ flushIfDirty();
542
+ requireLiveWatchers(root.path);
543
+ return { rootPath: root.path, record: rootRecordOf(root.rootId) };
544
+ }
545
+
546
+ /**
547
+ * Many explicit paths, one registration primitive. A batch does not invent
548
+ * a second transaction model: every selected root follows the exact same
549
+ * Register → Watch transition as a one-path `add`, in caller order. Input
550
+ * shape is validated before the first registration; thereafter each result
551
+ * is durable before its watcher begins, so a later watcher fault cannot
552
+ * falsely report an all-or-nothing batch after earlier identity committed.
553
+ *
554
+ * The object form is internal-only bootstrap support for remote-first UUID
555
+ * binding. The public CLI/API supplies strings and can never choose UUIDs.
556
+ */
557
+ function addMany(entries) {
558
+ if (!Array.isArray(entries) || entries.length === 0) {
559
+ throw new Error('addMany requires one or more target paths');
560
+ }
561
+ const normalized = entries.map((entry) => {
562
+ if (typeof entry === 'string') return { targetPath: entry, identityAt: null };
563
+ if (!entry || typeof entry !== 'object' || typeof entry.targetPath !== 'string') {
564
+ throw new Error('addMany entries must be paths or { targetPath, identityAt }');
565
+ }
566
+ if (entry.identityAt !== undefined && entry.identityAt !== null && typeof entry.identityAt !== 'function') {
567
+ throw new Error('addMany identityAt must be a function when supplied');
568
+ }
569
+ return { targetPath: entry.targetPath, identityAt: entry.identityAt ?? null };
570
+ });
571
+ return normalized.map(({ targetPath, identityAt }) => add(targetPath, { identityAt }));
572
+ }
573
+
574
+ /** The boundary's whole convergence pass, one act: loss convicted
575
+ * first (loudly — a conviction is never a retry), edges followed,
576
+ * lenses reconciled, renderings projected. Startup, the explicit
577
+ * call, and the event-scheduled one all run this same code. */
578
+ function flushIfDirty() {
579
+ ensureTrees();
580
+ if (edgesDirty) tryFollow();
581
+ let clean = !edgesDirty;
582
+ for (const lens of lenses.values()) clean = lens.flushIfDirty() && clean;
583
+ maintainReferences(); // renderings follow their targets' current addresses
584
+ // The filesystem lens owns ordinary ground. A repo's detector is its
585
+ // Repocard follower: raw repo doorbells become settled Card + Checkpoint
586
+ // state, then authorize one reconciliation of registered worktree leaves.
587
+ repoFollowers.sync();
588
+ return clean;
589
+ }
590
+
591
+ // Startup is the same act as running — the one convergence pass,
592
+ // nothing less. The projection must not wait for an event: a
593
+ // rendering whose deletion was detected before a crash is equal
594
+ // truth at the catch-up scan — silence — and silence never heals.
595
+ edgesDirty = true;
596
+ flushIfDirty();
597
+
598
+ return {
599
+ // The service-level cloud boundary consumes the logical registry only;
600
+ // observer bindings remain private to this registration composition.
601
+ registry,
602
+ add,
603
+ addMany,
604
+
605
+ /**
606
+ * `amalgm register <existing-path> --as <address>` — the managed
607
+ * reference, the OTHER link kind (never conflated with a filesystem
608
+ * symlink): created only by this deliberate act, its payload is the
609
+ * TARGET ENTITY'S UUID, and its local rendering as an ordinary
610
+ * symlink at `atPath` is derived state this boundary maintains. The
611
+ * target registers first through the same `add` operation; the
612
+ * reference then lives inside already-registered ground (the Amalgm
613
+ * home), so an observed target move rewrites the rendering while
614
+ * the reference and target UUIDs never change. An unavailable
615
+ * target leaves the rendering dangling — honest — until rescue or
616
+ * reattachment repairs it.
617
+ */
618
+ addReference(targetPath, atPath) {
619
+ const target = add(targetPath);
620
+ const real = fs.lstatSync(targetPath).isSymbolicLink() ? targetPath : fs.realpathSync(targetPath);
621
+ const targetUuid = resolveGround(real) ?? target.record.uuid;
622
+ const parent = fs.realpathSync(path.dirname(atPath));
623
+ const address = path.join(parent, path.basename(atPath));
624
+ const home = treeRootCovering(parent);
625
+ if (!home || home.kind !== 'files') {
626
+ throw refusal(`a reference lives inside registered workspace ground: ${address} is not covered by one — add its workspace first`);
627
+ }
628
+ const parentRel = parent === home.path ? '' : parent.slice(home.path.length + 1);
629
+ const owner = owningRecordAt(rootRecordOf(home.rootId), parentRel);
630
+ if (!owner) {
631
+ throw new Error(`no registered entity owns ${parent} yet — flush the boundary before placing a reference there`);
632
+ }
633
+ // The deliberate act is IDEMPOTENT: asking for a reference that
634
+ // already exists converges its rendering below instead of refusing
635
+ // — a failed first attempt is retried by repeating it. Any OTHER
636
+ // occupant, record or ground, refuses: one owner per path.
637
+ const existing = registry.activeAt(owner.uuid, path.basename(atPath));
638
+ if (existing && registry.references()
639
+ .find((ref) => ref.record.uuid === existing.uuid)?.targetUUID !== targetUuid) {
640
+ throw refusal(`refusing to render a reference over existing ground at ${address} — one owner per path`);
641
+ }
642
+ if (!existing) {
643
+ let occupied = true;
644
+ try {
645
+ fs.lstatSync(address);
646
+ } catch {
647
+ occupied = false;
648
+ }
649
+ if (occupied) {
650
+ throw refusal(`refusing to render a reference over existing ground at ${address} — one owner per path`);
651
+ }
652
+ }
653
+ const record = existing ?? registry.create({
654
+ type: 'reference', parentUUID: owner.uuid, name: path.basename(atPath),
655
+ payloadVersion: targetUuid,
656
+ });
657
+ // The record is canonical; the rendering is the same idempotent
658
+ // projection every flush performs — projected first so the full
659
+ // pass that follows binds the rendering's channel to the record
660
+ // (attesting nothing).
661
+ maintainReferences();
662
+ flushIfDirty();
663
+ return { record, targetUUID: targetUuid };
664
+ },
665
+
666
+ /** INFALLIBLE by construction: route the hint, mark edge news dirty,
667
+ * schedule, return. Wire the observer's emit here. The scheduled
668
+ * pass is the FULL flush — reference maintenance included, so
669
+ * renderings converge after events, never only on an explicit ask —
670
+ * with failures warned and left dirty for the next pass. */
671
+ onEvent(event) {
672
+ if (stopped) return;
673
+ repoFollowers.onObserverEvent(event);
674
+ if (EDGE_EVENTS.has(event.type)) edgesDirty = true;
675
+ notifyEventOwner(event);
676
+ if (scheduled) return;
677
+ scheduled = true;
678
+ const immediate = setImmediate(() => {
679
+ scheduled = false;
680
+ if (stopped) return; // the world closed under an already-queued pass
681
+ try {
682
+ flushIfDirty();
683
+ } catch (error) {
684
+ console.warn('[Registration] scheduled flush failed (stays dirty):', error?.message || error);
685
+ }
686
+ });
687
+ immediate.unref?.();
688
+ },
689
+
690
+ /**
691
+ * Shut down: schedule no more work, and honor no more hints —
692
+ * EVERY lens included. The boundary owns no handles (its stores
693
+ * belong to whoever opened them) but it and its lenses each own a
694
+ * pending pass, and a pass that lands after those databases close
695
+ * would warn about a shutdown that went exactly right. One stop
696
+ * reaches all of them, because the boundary is what created them.
697
+ * Dirty state stays dirty on purpose: the next boundary over this
698
+ * ground reconciles it at birth, which is the same act.
699
+ */
700
+ stop() {
701
+ stopped = true;
702
+ repoFollowers.stop();
703
+ for (const lens of lenses.values()) lens.stop();
704
+ },
705
+
706
+ flushIfDirty,
707
+
708
+ /**
709
+ * Derived, never stored: what each link's written address currently
710
+ * reaches in entity space (link uuid → owning uuid, or null), across
711
+ * EVERY registered tree. Resolution is the OS's own — realpath,
712
+ * chains and all — then the real ground's tree answers for the
713
+ * address with the entity that OWNS it: the exact record at the address.
714
+ * A repository boundary changes its realtime semantics, not the child
715
+ * identity map. It dies honestly when
716
+ * the target moves out from under the text: amalgm remembers the
717
+ * pointer, never what it used to reach. null = broken or
718
+ * unregistered ground.
719
+ */
720
+ linkTargets() {
721
+ const out = new Map();
722
+ for (const root of observer.listRoots()) {
723
+ const home = treeRootCovering(root.path);
724
+ const lens = home ? lenses.get(home.rootId) : null;
725
+ if (!lens) continue;
726
+ for (const row of observer.linksForRoot(root.rootId)) {
727
+ const uuid = lens.boundUuid(row.linkId);
728
+ if (uuid === undefined) continue;
729
+ let real;
730
+ try {
731
+ real = fs.realpathSync(path.resolve(edgeBase(root, row.relPath), row.target));
732
+ } catch {
733
+ out.set(uuid, null); // broken: the derived resolution is dead, the record is not
734
+ continue;
735
+ }
736
+ out.set(uuid, resolveGround(real));
737
+ }
738
+ }
739
+ return out;
740
+ },
741
+
742
+ /** The latest edge pass's refusals — visible, never silent. */
743
+ reported: () => [...reported],
744
+ repoState: (uuid) => repoStates.get(uuid),
745
+ tree: treeAt,
746
+ trees: () => new Map(treeRoots()
747
+ .filter((root) => lenses.has(root.rootId))
748
+ .map((root) => [root.path, treeAt(root.path)])),
749
+ };
750
+ }
751
+
752
+ module.exports = { createRegistration };