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,1146 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The observer: the single place where a local change is born.
5
+ *
6
+ * Wiring only — watch.js rings, scan.js compares, store.js remembers, and
7
+ * this file turns compare results into events. A doorbell never emits
8
+ * anything itself; it only makes the next scan happen sooner (axiom 3).
9
+ *
10
+ * Territory: a .git claims its subtree. Files territory gets channels and
11
+ * hashes — directories included: a directory is a channel with physical
12
+ * identity and no content of its own, so folder renames survive by inode
13
+ * exactly like file renames, and empty folders exist the moment disk holds
14
+ * them. Repo territory gets a raw, instant doorbell (`repo.changed`) — the
15
+ * observer never editorializes repo events, because quieting and identity
16
+ * belong to the follower (repocard/follow.js), and a delayed doorbell would
17
+ * blind its torn-read guard. Territory is decided by disk, not by callers:
18
+ * a .git appearing anywhere converts the ground under it, and its
19
+ * disappearance converts it back.
20
+ *
21
+ * Nested repositories are territories too: a repo inside a repo is simply
22
+ * another repo. A census at lifecycle moments (enrollment, conversion,
23
+ * catch-up scans) plus doorbells live make each nested boundary its own
24
+ * root with its own watcher; the parent stays silent for a child's ground.
25
+ * A doorbell's filename never proves anything — it only says where to
26
+ * look, and disk decides what it means (a repo moved in whole rings only
27
+ * the directory that landed, never a path spelling ".git").
28
+ *
29
+ * Enrollment is amalgm's OWN policy (`shouldEnroll`), applied uniformly to
30
+ * files territory and repo boundaries alike. Git's ignore rules never
31
+ * decide it (entity axiom 2, docs/entity-model.md): a gitignored worktree
32
+ * or nested repo in enrolled ground is a territory; a repo inside
33
+ * amalgm-excluded ground (node_modules, caches) is not.
34
+ */
35
+
36
+ const crypto = require('crypto');
37
+ const fs = require('fs');
38
+ const path = require('path');
39
+
40
+ const { createApply } = require('./apply');
41
+ const { createContinuity } = require('./continuity');
42
+ const { createStore } = require('./store');
43
+ const { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, walkRoot } = require('./scan');
44
+ const { createVerify } = require('./verify');
45
+ const { watchDir, watchRecursive } = require('./watch');
46
+ const { adapterFor } = require('../adapters');
47
+ const { lookFailed } = require('../adapters/truth');
48
+
49
+ // The production stitch (ratified 2026-08-02): the observer's content
50
+ // decision reads file truth through the adapter socket — the same
51
+ // three-state answer every surface speaks. Both file kinds declare the
52
+ // same truth (bytes), so either selects the one filesystem adapter;
53
+ // the text/binary split is classification policy downstream.
54
+ const fileTruth = adapterFor('file.binary').rails.detect.truth;
55
+
56
+ const DEFAULT_SETTLE_MS = 200;
57
+ // Surviving suspicion backs off exponentially from settleMs to here:
58
+ // permanently unsettled ground costs a heartbeat, never a hot loop.
59
+ const RETRY_CAP_MS = 60_000;
60
+
61
+ function defaultShouldEnroll(relPath) {
62
+ return require('../workspace/tree/inclusion').shouldEnroll(relPath);
63
+ }
64
+
65
+ function createObserver(options) {
66
+ const {
67
+ db,
68
+ emit,
69
+ shouldEnroll = defaultShouldEnroll,
70
+ settleMs = DEFAULT_SETTLE_MS,
71
+ // The entity-content cache is injected by the registration composition
72
+ // root. Detection still owns the observed hash; this hook only retains
73
+ // the exact bytes behind that hash for the later cloud rail.
74
+ captureContent = null,
75
+ // The doorbell implementation. Injectable so tests can ring through the
76
+ // production wiring what fs.watch rarely delivers (a null filename).
77
+ watch = watchRecursive,
78
+ } = options;
79
+ if (!db) throw new Error('createObserver requires { db }');
80
+ if (typeof emit !== 'function') throw new Error('createObserver requires { emit }');
81
+
82
+ const store = createStore(db);
83
+ // rootId -> { kind, watcher, settleTimer, dirtyPaths, checkAll, territories }
84
+ const live = new Map();
85
+ let started = false;
86
+
87
+ function publish(event) {
88
+ try {
89
+ emit(event);
90
+ } catch (error) {
91
+ console.warn('[Observer] emit failed:', error?.message || error);
92
+ }
93
+ }
94
+
95
+ /** A file's settled bytes right now, through the adapter socket —
96
+ * their fingerprint AND their kind, from the one read that produced
97
+ * both — or null when its truth is absent, unsettled, or no longer a
98
+ * file's. For the writer's own record (noteWrite), settled-or-null
99
+ * is the whole question; the SCAN's content read is scanContent
100
+ * below, where unsettled suspicion survives, and the referee
101
+ * (verify) reads the three states directly. */
102
+ function safeContent(absPath) {
103
+ const read = fileTruth(absPath);
104
+ if (read.truth !== 'present' || read.state.kind !== 'file') return null;
105
+ try {
106
+ captureContent?.({ filePath: absPath, contentHash: read.state.sha256 });
107
+ } catch {
108
+ return null;
109
+ }
110
+ return { contentHash: read.state.sha256, binary: read.state.binary };
111
+ }
112
+
113
+ /** One pending look per root, earliest due wins: a doorbell asks at
114
+ * settleMs and a surviving suspicion asks at its backed-off delay —
115
+ * a shorter ask replaces a longer wait (new evidence resets a
116
+ * backoff), while a pending sooner look absorbs later asks (rings
117
+ * inside the settle window still batch into one scan). */
118
+ function armSettle(root, state, delay = settleMs) {
119
+ const due = Date.now() + delay;
120
+ if (state.settleTimer) {
121
+ if (due >= state.settleDue) return;
122
+ clearTimeout(state.settleTimer);
123
+ }
124
+ state.settleDue = due;
125
+ state.settleTimer = setTimeout(() => {
126
+ state.settleTimer = null;
127
+ try {
128
+ scanNow(root.rootId);
129
+ } catch (error) {
130
+ console.warn(`[Observer] scan failed for "${root.path}":`, error?.message || error);
131
+ }
132
+ }, delay);
133
+ state.settleTimer.unref?.();
134
+ }
135
+
136
+ /** The one scheduling rule for suspicion that survives a scan:
137
+ * retries coalesce on the root's single pending look and back off
138
+ * exponentially toward RETRY_CAP_MS. Any new doorbell evidence
139
+ * resets the delay — ring() asks at settleMs, which replaces a
140
+ * longer wait in armSettle. */
141
+ function armRetry(root, state) {
142
+ if (state.settleTimer) return; // coalesce: one escalation per armed look
143
+ const delay = state.retryMs;
144
+ state.retryMs = Math.min(RETRY_CAP_MS, state.retryMs * 2);
145
+ armSettle(root, state, delay);
146
+ }
147
+
148
+ /** The scan's content read, all three truths honored: settled bytes
149
+ * for a present file; null when the ground is absent or no longer a
150
+ * file's — the walk's next look decides; and on UNSETTLED the
151
+ * suspicion SURVIVES: the path returns to the dirty set and the
152
+ * retry re-arms, so the read that reaches present or absent is
153
+ * scheduled by the observer itself — an unsettled read means "read
154
+ * again", and detection is who reads again. One transiently
155
+ * unreadable moment can never swallow a change. */
156
+ function scanContent(root, relPath) {
157
+ const read = fileTruth(path.join(root.path, relPath));
158
+ if (read.truth === 'unsettled') {
159
+ const state = live.get(root.rootId);
160
+ if (state) {
161
+ state.dirtyPaths.add(relPath);
162
+ armRetry(root, state);
163
+ }
164
+ return null;
165
+ }
166
+ if (read.truth !== 'present' || read.state.kind !== 'file') return null;
167
+ try {
168
+ captureContent?.({ filePath: path.join(root.path, relPath), contentHash: read.state.sha256 });
169
+ } catch {
170
+ // A file that changed between the observer's hash and the immutable
171
+ // capture is not a stable cloud head yet. Preserve suspicion and read
172
+ // again; recording the hash without its bytes would create a dangling
173
+ // cloud entity head.
174
+ const state = live.get(root.rootId);
175
+ if (state) {
176
+ state.dirtyPaths.add(relPath);
177
+ armRetry(root, state);
178
+ }
179
+ return null;
180
+ }
181
+ return { contentHash: read.state.sha256, binary: read.state.binary };
182
+ }
183
+
184
+ /** Symlinks are never followed, matching the walks. */
185
+ function isDirectory(absPath) {
186
+ try {
187
+ return fs.lstatSync(absPath).isDirectory();
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ // Compare results become truth in apply.js — events + store rows,
194
+ // deterministic order, per channel kind. The content read is injected:
195
+ // scanContent keeps unsettled suspicion on the root's retry, above.
196
+ const { applyDiff, applyDirDiff, applyLinkDiff } = createApply({ store, publish, scanContent });
197
+
198
+ function childRepoRoots(filesRoot) {
199
+ return store.listRoots().filter(
200
+ (root) => root.kind === 'repo' && root.path.startsWith(`${filesRoot.path}/`),
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Make the enrolled repo roots match the territories the walk reported:
206
+ * a new .git births a repo root, a vanished one dies. The files walk
207
+ * already skipped the claimed subtrees, so channels and territories can
208
+ * never overlap.
209
+ */
210
+ function reconcileTerritories(root, state, repoDirs) {
211
+ const claimed = new Set(repoDirs);
212
+ for (const relDir of repoDirs) {
213
+ const childPath = path.join(root.path, relDir);
214
+ if (store.rootByPath(childPath)) continue;
215
+ birthRepo(childPath);
216
+ }
217
+ for (const child of childRepoRoots(root)) {
218
+ const relDir = child.path.slice(root.path.length + 1);
219
+ if (claimed.has(relDir)) continue;
220
+ const territory = gitDirTruth(child.path);
221
+ if (territory !== 'absent') {
222
+ claimed.add(relDir); // deeper than the walk descends, or unreadable — only absence buries
223
+ if (territory === 'unsettled' && state) armRetry(root, state); // and unsettled stays pending
224
+ continue;
225
+ }
226
+ detach(child.rootId);
227
+ store.removeRoot(child.rootId);
228
+ publish({ type: 'repo.removed', rootId: child.rootId, path: child.path });
229
+ }
230
+ if (state) state.territories = claimed;
231
+ }
232
+
233
+ /**
234
+ * Birth a repo territory: enroll, watch, announce, census its own children.
235
+ *
236
+ * TEMPORARY IDENTITY CONTRACT: minting a fresh rootId here (and deleting
237
+ * it in removeRepoChild) is internal behavior, not the model. Under the
238
+ * entity axioms (docs/entity-model.md) a nested folder ↔ repo change is a
239
+ * TYPE TRANSITION on one permanent UUID; that lands with folder entities.
240
+ * Standalone roots already behave correctly (becomeRepo/becomeFiles keep
241
+ * their rootId) — do not build on the nested births' identity churn.
242
+ */
243
+ function birthRepo(childPath) {
244
+ let stat = null;
245
+ try {
246
+ stat = fs.lstatSync(childPath);
247
+ } catch {
248
+ stat = null; // vanished mid-birth; identity arrives with the next census
249
+ }
250
+ const child = {
251
+ rootId: crypto.randomUUID(), path: childPath, kind: 'repo',
252
+ device: stat?.dev ?? null, inode: stat?.ino ?? null,
253
+ };
254
+ store.addRoot(child);
255
+ attach(child);
256
+ publish({ type: 'repo.discovered', rootId: child.rootId, path: child.path });
257
+ censusRepoRoot(child);
258
+ return child;
259
+ }
260
+
261
+ function removeRepoChild(child) {
262
+ detach(child.rootId);
263
+ store.removeRoot(child.rootId);
264
+ publish({ type: 'repo.removed', rootId: child.rootId, path: child.path });
265
+ }
266
+
267
+ /**
268
+ * Nested repositories are territories too — a repo inside a repo is just
269
+ * another repo. Census the ground for `.git` boundaries, birth the
270
+ * missing, bury the dead. Runs at lifecycle moments (enrollment,
271
+ * conversion, catch-up scans), never on a timer; between moments the
272
+ * `.git` doorbells in ring() carry the news live.
273
+ */
274
+ function censusRepoRoot(root) {
275
+ const state = live.get(root.rootId);
276
+ const { boundaries, unsettled } = censusBoundaries(root.path, shouldEnroll);
277
+ const claimed = new Set(boundaries);
278
+ for (const relDir of boundaries) {
279
+ const childPath = path.join(root.path, relDir);
280
+ if (!store.rootByPath(childPath)) birthRepo(childPath);
281
+ }
282
+ for (const child of store.listRoots()) {
283
+ if (child.kind !== 'repo' || child.rootId === root.rootId) continue;
284
+ if (!child.path.startsWith(`${root.path}/`)) continue;
285
+ const relDir = child.path.slice(root.path.length + 1);
286
+ if (claimed.has(relDir)) continue;
287
+ if ([...claimed].some((owned) => relDir.startsWith(`${owned}/`))) continue; // a child's child
288
+ const territory = gitDirTruth(child.path);
289
+ if (territory !== 'absent') {
290
+ claimed.add(relDir); // existence wins — and only absence buries
291
+ if (territory === 'unsettled' && state) armRetry(root, state);
292
+ continue;
293
+ }
294
+ removeRepoChild(child);
295
+ }
296
+ // Unlisted ground may hide a boundary the census could not see:
297
+ // suspicion stays pending until the whole ground can be read.
298
+ if (unsettled.length > 0 && state) armRetry(root, state);
299
+ if (state) state.territories = claimed;
300
+ }
301
+
302
+ /** The root itself grew a .git: every channel closes, one repo is born. */
303
+ function becomeRepo(root, state) {
304
+ const rows = store.filesForRoot(root.rootId)
305
+ .sort((a, b) => (a.relPath < b.relPath ? -1 : 1));
306
+ for (const row of rows) {
307
+ store.removeFile(row.fileId);
308
+ publish({ type: 'file.deleted', rootId: root.rootId, fileId: row.fileId, path: row.relPath });
309
+ }
310
+ for (const row of store.linksForRoot(root.rootId)) {
311
+ store.removeLink(row.linkId);
312
+ publish({ type: 'link.deleted', rootId: root.rootId, linkId: row.linkId, path: row.relPath });
313
+ }
314
+ for (const row of store.dirsForRoot(root.rootId)) {
315
+ store.removeDir(row.dirId);
316
+ publish({ type: 'dir.deleted', rootId: root.rootId, dirId: row.dirId, path: row.relPath });
317
+ }
318
+ store.setRootKind(root.rootId, 'repo');
319
+ if (state) {
320
+ state.kind = 'repo';
321
+ state.dirtyPaths.clear();
322
+ state.checkAll = false;
323
+ state.territories = new Set();
324
+ }
325
+ publish({ type: 'repo.discovered', rootId: root.rootId, path: root.path });
326
+ censusRepoRoot(root);
327
+ }
328
+
329
+ /** A repo root's .git is gone: the ground reverts to whoever encloses it. */
330
+ function becomeFiles(root, state) {
331
+ publish({ type: 'repo.removed', rootId: root.rootId, path: root.path });
332
+ const parent = store.listRoots()
333
+ .filter((other) => other.rootId !== root.rootId && root.path.startsWith(`${other.path}/`))
334
+ .sort((a, b) => b.path.length - a.path.length)[0]; // nearest encloser decides
335
+ if (parent && parent.kind === 'repo') {
336
+ // Born inside repo territory, returned to it: the ground is the
337
+ // parent's worktree again, and its next capture is the reclaim.
338
+ detach(root.rootId);
339
+ store.removeRoot(root.rootId);
340
+ live.get(parent.rootId)?.territories.delete(root.path.slice(parent.path.length + 1));
341
+ publish({ type: 'repo.changed', rootId: parent.rootId, areas: ['worktree'] });
342
+ return;
343
+ }
344
+ if (parent) {
345
+ // Born from a files root, returned to it: the parent's next scan
346
+ // reclaims the files as creates.
347
+ detach(root.rootId);
348
+ store.removeRoot(root.rootId);
349
+ live.get(parent.rootId)?.territories.delete(root.path.slice(parent.path.length + 1));
350
+ scanFilesRoot(parent);
351
+ return;
352
+ }
353
+ store.setRootKind(root.rootId, 'files');
354
+ if (state) state.kind = 'files';
355
+ scanFilesRoot({ ...root, kind: 'files' });
356
+ }
357
+
358
+ function scanFilesRoot(root, { allowRootRepository = false } = {}) {
359
+ // Drain the doorbell's named hints first: anything that rings during the
360
+ // walk below belongs to the next scan, not this one.
361
+ const state = live.get(root.rootId);
362
+ const checkAll = Boolean(state?.checkAll);
363
+ const rung = state ? new Set(state.dirtyPaths) : new Set();
364
+ if (state) {
365
+ state.dirtyPaths.clear();
366
+ state.checkAll = false;
367
+ }
368
+
369
+ const { files: entries, dirs, links, repoDirs, unsettled } = walkRoot(
370
+ root.path,
371
+ shouldEnroll,
372
+ { allowRootRepository },
373
+ );
374
+ if (repoDirs.includes('')) {
375
+ becomeRepo(root, state);
376
+ return;
377
+ }
378
+ reconcileTerritories(root, state, repoDirs);
379
+
380
+ // Only present or absent may conclude: ground the walk could not
381
+ // read keeps every belief beneath it — a delete is a conclusion,
382
+ // and none may be drawn there — while the suspicion stays pending
383
+ // on the root's backed-off retry.
384
+ const underUnsettled = (relPath) =>
385
+ unsettled.some((dir) => dir === '' || relPath === dir || relPath.startsWith(`${dir}/`));
386
+ const concluded = (diff) => (unsettled.length === 0 ? diff
387
+ : { ...diff, deletes: diff.deletes.filter((row) => !underUnsettled(row.relPath)) });
388
+ if (unsettled.length > 0 && state) armRetry(root, state);
389
+
390
+ applyDirDiff(root, concluded(diffDirState(store.dirsForRoot(root.rootId), dirs)));
391
+ applyLinkDiff(root, concluded(diffLinkState(store.linksForRoot(root.rootId), links)));
392
+ const rows = store.filesForRoot(root.rootId);
393
+ const mustCheck = checkAll ? new Set(entries.map((entry) => entry.relPath)) : rung;
394
+ applyDiff(root, concluded(diffRootState(rows, entries, mustCheck)));
395
+ }
396
+
397
+ /**
398
+ * A single-ground root: one channel, the same laws, the smallest ground
399
+ * there is. The root's path IS the ground, its relPath is '' — and disk
400
+ * decides the channel's kind each scan: a regular file feeds the file
401
+ * compare (path claims the channel, so an atomic save's inode churn is
402
+ * absorbed; the racily-clean stat excuse and the doorbell-named-must-hash
403
+ * rule apply verbatim), a symlink feeds the link compare (the written
404
+ * target is the whole payload, read fresh, never followed — an
405
+ * explicitly added pointer is a root of the pointer kind). Ground that
406
+ * is neither never reaches this scan: the root's own address is the
407
+ * evidence horizon's edge, so its absence is undecidable and freezes
408
+ * upstream (addressUndecided) rather than closing the channel.
409
+ */
410
+ function scanFileRoot(root) {
411
+ const state = live.get(root.rootId);
412
+ const checkAll = Boolean(state?.checkAll);
413
+ const rung = state ? new Set(state.dirtyPaths) : new Set();
414
+ if (state) {
415
+ state.dirtyPaths.clear();
416
+ state.checkAll = false;
417
+ }
418
+ let stat = null;
419
+ try {
420
+ stat = fs.lstatSync(root.path);
421
+ } catch {
422
+ stat = null;
423
+ }
424
+ const entries = stat && stat.isFile()
425
+ ? [{ relPath: '', device: stat.dev, inode: stat.ino, size: stat.size, mtimeMs: Math.floor(stat.mtimeMs) }]
426
+ : [];
427
+ let linkEntries = [];
428
+ if (stat && stat.isSymbolicLink()) {
429
+ try {
430
+ linkEntries = [{ relPath: '', device: stat.dev, inode: stat.ino, target: fs.readlinkSync(root.path) }];
431
+ } catch (error) {
432
+ // Vanished mid-read is honest absence (the empty compare below
433
+ // concludes it); a failed look concludes nothing — the one
434
+ // channel keeps its belief and the retry reads again.
435
+ if (lookFailed(error).truth === 'unsettled') {
436
+ if (state) armRetry(root, state);
437
+ return;
438
+ }
439
+ linkEntries = [];
440
+ }
441
+ }
442
+ applyLinkDiff(root, diffLinkState(store.linksForRoot(root.rootId), linkEntries));
443
+ const mustCheck = checkAll ? new Set(entries.map((entry) => entry.relPath)) : rung;
444
+ applyDiff(root, diffRootState(store.filesForRoot(root.rootId), entries, mustCheck));
445
+ }
446
+
447
+ // Continuity questions and the presence bit live in continuity.js;
448
+ // the ACTIONS a verdict triggers (rebindRoot, decideUndecidedAddress)
449
+ // stay here, because they re-aim watchers and scans.
450
+ const {
451
+ addressUndecided, findMovedRoot, markAbsent, markPresent, rootIdentity,
452
+ } = createContinuity({ store, publish });
453
+
454
+ /**
455
+ * The rescue: same ground, new address. The root — and every enrolled
456
+ * territory inside it, whose store rows record absolute paths — moves
457
+ * as an ADDRESS ONLY: rootId, channels, and bindings all survive, and
458
+ * the follow-up scan proves silence when the ground truly just moved.
459
+ */
460
+ function rebindRoot(root, newPath) {
461
+ const oldPath = root.path;
462
+ const family = store.listRoots()
463
+ .filter((row) => row.rootId === root.rootId || row.path.startsWith(`${oldPath}/`));
464
+ // Truth first, as one transition — the whole family lands or none of
465
+ // it does, so a failure mid-move tears nothing. Doorbells re-aim only
466
+ // after truth has landed, exactly like the directory batches inside.
467
+ store.moveRoots(family.map((member) => ({
468
+ rootId: member.rootId,
469
+ path: member.rootId === root.rootId ? newPath : newPath + member.path.slice(oldPath.length),
470
+ })));
471
+ for (const member of family) {
472
+ detach(member.rootId);
473
+ attach(store.listRoots().find((row) => row.rootId === member.rootId));
474
+ }
475
+ publish({ type: 'root.moved', rootId: root.rootId, from: oldPath, to: newPath });
476
+ try {
477
+ scanNow(root.rootId);
478
+ } catch (error) {
479
+ console.warn(`[Observer] post-rebind scan failed for "${newPath}":`, error?.message || error);
480
+ }
481
+ return store.listRoots().find((row) => row.rootId === root.rootId);
482
+ }
483
+
484
+ function scanNow(rootId) {
485
+ const roots = rootId
486
+ ? store.listRoots().filter((root) => root.rootId === rootId)
487
+ : store.listRoots();
488
+ for (const stale of roots) {
489
+ // A scan earlier in this loop may have converted or removed a later
490
+ // root (territory handoff); the store row is the fresh truth.
491
+ const root = store.rootByPath(stale.path);
492
+ if (!root) continue;
493
+ if (addressUndecided(root)) {
494
+ // The same decision the address doorbell makes — whichever
495
+ // entrance finds the undecided address first, the verdict is
496
+ // identical, so a settle scan winning the race against a slow
497
+ // parent delivery follows instead of flapping presence.
498
+ decideUndecidedAddress(root);
499
+ continue;
500
+ }
501
+ markPresent(root);
502
+ if (root.kind === 'file') {
503
+ scanFileRoot(root);
504
+ } else if (root.kind === 'files') {
505
+ scanFilesRoot(root);
506
+ } else {
507
+ const territory = gitDirTruth(root.path);
508
+ if (territory === 'absent') {
509
+ becomeFiles(root, live.get(root.rootId));
510
+ } else if (territory === 'unsettled') {
511
+ // A failed look concludes nothing: the territory stands, the
512
+ // suspicion stays pending, and the settled look will bring
513
+ // the catch-up doorbell.
514
+ const state = live.get(root.rootId);
515
+ if (state) armRetry(root, state);
516
+ } else {
517
+ // Territory changes while nobody watched left no doorbell
518
+ // either: the catch-up census is their event.
519
+ censusRepoRoot(root);
520
+ // Catch-up semantics: the observer cannot know what happened to a
521
+ // repo while nobody watched; the follower's ids turn "maybe" into
522
+ // silence, so an unconditional doorbell is the honest answer.
523
+ publish({ type: 'repo.changed', rootId: root.rootId, areas: ['git', 'worktree'] });
524
+ }
525
+ }
526
+ // A scan that leaves no look pending resets the backoff: the
527
+ // next unsettled episode starts fresh from the settle delay.
528
+ const after = live.get(root.rootId);
529
+ if (after && !after.settleTimer) after.retryMs = settleMs;
530
+ }
531
+ }
532
+
533
+ function coveringTerritory(state, relPath) {
534
+ for (const territory of state.territories) {
535
+ if (relPath === territory || relPath.startsWith(`${territory}/`)) return territory;
536
+ }
537
+ return null;
538
+ }
539
+
540
+ function underTerritory(state, relPath) {
541
+ return coveringTerritory(state, relPath) !== null;
542
+ }
543
+
544
+ function ring(root, relPath) {
545
+ const state = live.get(root.rootId);
546
+ if (!state) return;
547
+ state.retryMs = settleMs; // new evidence resets a backoff
548
+
549
+ if (state.kind === 'file') {
550
+ // The parent-directory doorbell already filtered to this file (or
551
+ // to nothing named, which could be anything): the one channel is
552
+ // suspect either way, and a named event always hashes.
553
+ state.dirtyPaths.add('');
554
+ armSettle(root, state);
555
+ return;
556
+ }
557
+
558
+ if (state.kind === 'repo') {
559
+ const gitArea = relPath === null || relPath === '.git' || relPath.startsWith('.git/');
560
+ if (gitArea) {
561
+ const territory = gitDirTruth(root.path);
562
+ if (territory === 'absent') {
563
+ becomeFiles(root, state);
564
+ return;
565
+ }
566
+ if (territory === 'unsettled') {
567
+ // A failed look concludes nothing: no conversion, no doorbell
568
+ // forwarded on unknown ground — the retry looks again, and the
569
+ // settled look brings the catch-up doorbell.
570
+ armRetry(root, state);
571
+ return;
572
+ }
573
+ }
574
+ // The filename only says where to look; disk decides what it means.
575
+ // Four tiers, cheapest first; every check is a single shot on this
576
+ // event, never a poll. (Files roots need none of this: their scans
577
+ // re-walk the ground, and reconcileTerritories is their reconcile.)
578
+ if (relPath === null) {
579
+ // No name at all: the only honest reading is a full reconcile.
580
+ censusRepoRoot(root);
581
+ } else {
582
+ const territory = coveringTerritory(state, relPath);
583
+ if (territory !== null) {
584
+ // A live child territory's ground answers to its own root: its
585
+ // watcher rings it, its follower captures it. A dead boundary is
586
+ // territory news that never spells ".git" (a move-out rings only
587
+ // the directory that left): bury the child, the ground is the
588
+ // parent's worktree again, and the forward below is the reclaim.
589
+ const childPath = path.join(root.path, territory);
590
+ const childTruth = gitDirTruth(childPath);
591
+ if (childTruth !== 'absent') {
592
+ // Only absence buries — and an unsettled look keeps the news
593
+ // pending: the retry looks again, no second doorbell needed.
594
+ if (childTruth === 'unsettled') armRetry(root, state);
595
+ return;
596
+ }
597
+ const known = store.rootByPath(childPath);
598
+ if (known && known.kind === 'repo') removeRepoChild(known);
599
+ state.territories.delete(territory);
600
+ } else {
601
+ // Territories BELOW the named path: a move-out may name only an
602
+ // ancestor folder of a boundary (`mv vendor` away when the
603
+ // territory is vendor/lib). Existence is checked for every
604
+ // territory the named path contains.
605
+ for (const below of [...state.territories]) {
606
+ if (!below.startsWith(`${relPath}/`)) continue;
607
+ const belowPath = path.join(root.path, below);
608
+ const belowTruth = gitDirTruth(belowPath);
609
+ if (belowTruth !== 'absent') {
610
+ if (belowTruth === 'unsettled') armRetry(root, state); // only absence buries; unsettled stays pending
611
+ continue;
612
+ }
613
+ const known = store.rootByPath(belowPath);
614
+ if (known && known.kind === 'repo') removeRepoChild(known);
615
+ state.territories.delete(below);
616
+ }
617
+ // Unclaimed ground: any directory on the doorbell's path could be
618
+ // a boundary that never spelled ".git" (a move-in rings only the
619
+ // directory that landed; git init rings ".git/HEAD" under it).
620
+ // Walk the named path's prefixes. The nearest boundary claims;
621
+ // deeper ground is the child's own census's business.
622
+ const segments = relPath.split('/');
623
+ const gitIdx = segments.indexOf('.git');
624
+ const prefixEnd = gitIdx >= 0 ? gitIdx : segments.length;
625
+ let claimed = false;
626
+ let pruned = false;
627
+ for (let i = 1; i <= prefixEnd; i++) {
628
+ const rel = segments.slice(0, i).join('/');
629
+ // The policy prunes at the directory, exactly like the walks:
630
+ // excluded ground excludes everything under it, so the boundary
631
+ // may not be asked about point-wise.
632
+ if (!shouldEnroll(rel)) {
633
+ pruned = true;
634
+ break;
635
+ }
636
+ const childPath = path.join(root.path, rel);
637
+ const prefixTruth = gitDirTruth(childPath);
638
+ if (prefixTruth !== 'present') {
639
+ if (prefixTruth === 'unsettled') armRetry(root, state); // only presence births; unsettled stays pending
640
+ continue;
641
+ }
642
+ const known = store.rootByPath(childPath);
643
+ if (known) {
644
+ if (known.kind === 'repo') state.territories.add(rel); // heal a raced set
645
+ } else {
646
+ birthRepo(childPath);
647
+ state.territories.add(rel);
648
+ }
649
+ claimed = true;
650
+ break;
651
+ }
652
+ // A boundary may also sit DEEPER than the doorbell names (`mv
653
+ // pkg/` in when the repo lives at pkg/lib): when the named path
654
+ // is itself a plain directory on unclaimed, enrollable ground,
655
+ // walk it once for boundaries. The walk's paths are relative to
656
+ // the named directory, but the policy's contract is root-relative
657
+ // — re-prefix before asking, or a policy pruning `pkg/private`
658
+ // would not prune it here.
659
+ // (When gitIdx is -1 the loop's last prefix IS relPath, so an
660
+ // unpruned exit already vetted the named path itself.)
661
+ if (!claimed && !pruned && gitIdx === -1 && isDirectory(path.join(root.path, relPath))) {
662
+ const scopedEnroll = (rel) => shouldEnroll(`${relPath}/${rel}`);
663
+ const walked = censusBoundaries(path.join(root.path, relPath), scopedEnroll);
664
+ for (const sub of walked.boundaries) {
665
+ const rel = `${relPath}/${sub}`;
666
+ const childPath = path.join(root.path, rel);
667
+ if (!store.rootByPath(childPath)) {
668
+ birthRepo(childPath);
669
+ state.territories.add(rel);
670
+ }
671
+ }
672
+ // Unlisted ground may hide a boundary: suspicion stays pending.
673
+ if (walked.unsettled.length > 0) armRetry(root, state);
674
+ }
675
+ }
676
+ }
677
+ // Forwarded raw and instantly: quieting is the follower's job, and a
678
+ // delayed doorbell would blind its torn-read guard.
679
+ const areas = relPath === null
680
+ ? ['git', 'worktree']
681
+ : relPath === '.git' || relPath.startsWith('.git/') ? ['git'] : ['worktree'];
682
+ publish({ type: 'repo.changed', rootId: root.rootId, areas });
683
+ return;
684
+ }
685
+
686
+ // A child repo's own doorbell covers its territory — while the boundary
687
+ // is alive on disk. A dead one is territory news: fall through and let
688
+ // the scan's reconcile bury it and reclaim the ground.
689
+ const filesTerritory = relPath === null ? null : coveringTerritory(state, relPath);
690
+ if (filesTerritory !== null) {
691
+ const territoryTruth = gitDirTruth(path.join(root.path, filesTerritory));
692
+ if (territoryTruth === 'unsettled') {
693
+ armRetry(root, state); // no conclusion from a failed look — the retry decides
694
+ return;
695
+ }
696
+ if (territoryTruth === 'present') return;
697
+ }
698
+ // Enrollment policy applies to doorbells too — except .git itself: it is
699
+ // never enrollable, but its appearance is territory news and must scan.
700
+ const territoryNews = filesTerritory !== null
701
+ || (relPath !== null && relPath.split('/').includes('.git'));
702
+ if (relPath !== null && !territoryNews && !shouldEnroll(relPath)) return;
703
+ if (relPath === null) {
704
+ state.checkAll = true; // No filename given: every file is suspect.
705
+ } else if (!territoryNews) {
706
+ // A delivered event names its file; that file will be hashed no matter
707
+ // what its stats claim (timestamps can be backdated, doorbells can't).
708
+ state.dirtyPaths.add(relPath);
709
+ }
710
+ armSettle(root, state);
711
+ }
712
+
713
+ function openWatcher(root) {
714
+ // A single-file root's doorbell lives on its parent directory: a
715
+ // watcher on the file itself would follow the inode and go deaf on
716
+ // the first atomic save. The doorbell hears EVERYTHING in the parent
717
+ // — a rename may lawfully deliver only the NEW name — so events
718
+ // naming this file (or nothing, which could be anything) ring the
719
+ // content scan, and any foreign name is possible address news,
720
+ // answered by the cheap address check. Directory roots get the
721
+ // injected recursive doorbell.
722
+ if (root.kind === 'file') {
723
+ const name = path.basename(root.path);
724
+ return watchDir(path.dirname(root.path), (rung) => {
725
+ if (rung === null || rung === name) {
726
+ ring(root, rung);
727
+ return;
728
+ }
729
+ try {
730
+ checkAddress(root.rootId);
731
+ } catch (error) {
732
+ console.warn(`[Observer] address check failed for "${root.path}":`, error?.message || error);
733
+ }
734
+ }, () => reviveWatcher(root));
735
+ }
736
+ return watch(
737
+ root.path,
738
+ (relPath) => ring(root, relPath),
739
+ () => reviveWatcher(root),
740
+ );
741
+ }
742
+
743
+ /** A root enclosed by watched ground has its address observed already. */
744
+ function enclosed(root) {
745
+ return store.listRoots().some((other) =>
746
+ other.rootId !== root.rootId && root.path.startsWith(`${other.path}/`));
747
+ }
748
+
749
+ /**
750
+ * The parent observes address changes; the entity observes content
751
+ * changes. A top-level directory root's own recursive watcher can
752
+ * never see its address change, so a non-recursive doorbell on the
753
+ * parent directory — the same `watchDir` single-file roots ride —
754
+ * rings `checkAddress` on EVERY event there: a rename may deliver
755
+ * only the new name, so no name filter is lawful, and the cheap
756
+ * address check is what decides (an intact, present address returns
757
+ * in one lstat). A root inside another watched tree needs none: its
758
+ * encloser's walk is its address evidence (that arrival is adoption,
759
+ * one level up). Single-file roots' main doorbell IS a parent
760
+ * watcher, so their address news rings through openWatcher.
761
+ */
762
+ function openAddressWatcher(root) {
763
+ return watchDir(path.dirname(root.path), () => {
764
+ try {
765
+ checkAddress(root.rootId);
766
+ } catch (error) {
767
+ console.warn(`[Observer] address check failed for "${root.path}":`, error?.message || error);
768
+ }
769
+ }, () => reviveAddressWatcher(root));
770
+ }
771
+
772
+ function reviveAddressWatcher(root) {
773
+ const state = live.get(root.rootId);
774
+ if (!state || !started) return;
775
+ state.addressWatcher = openAddressWatcher(root);
776
+ try {
777
+ checkAddress(root.rootId);
778
+ } catch (error) {
779
+ console.warn(`[Observer] post-revive address check failed for "${root.path}":`, error?.message || error);
780
+ }
781
+ }
782
+
783
+ /**
784
+ * The address doorbell rang: decide what the root's address now means.
785
+ * Intact ground settles through the ordinary scan (which also flips
786
+ * presence back if the ground returned). Undecided ground goes to the
787
+ * one shared decision below — the same one every scan entrance makes.
788
+ */
789
+ function checkAddress(rootId) {
790
+ const root = store.listRoots().find((row) => row.rootId === rootId);
791
+ if (!root) return;
792
+ if (!addressUndecided(root)) {
793
+ // Intact address: content news is the root's own watcher's job —
794
+ // the parent doorbell also rings when the root's direct children
795
+ // change its entry, and scanning on that would double every scan.
796
+ // Only frozen ground RETURNING earns a reconcile here.
797
+ if (!root.present) scanNow(root.rootId);
798
+ return;
799
+ }
800
+ decideUndecidedAddress(root);
801
+ }
802
+
803
+ /**
804
+ * THE one decision for an undecided address, at EVERY entrance — the
805
+ * address doorbell, the settle scan racing a slow parent delivery,
806
+ * the catch-up scan at start. Two independent doorbells can learn of
807
+ * one rename in either order; the verdict may not depend on which
808
+ * arrived first, so both funnel here. Undecided ground of EVERY kind
809
+ * gets one directed look at the parent — a same-parent rename is the
810
+ * common move, and the root's kind-appropriate WITNESSED identity (a
811
+ * dir root its own inode; a single ground its channel row's, as of
812
+ * the last completed scan) claims the new name under the usual
813
+ * rules: exactly one candidate of the root's own shape. The path
814
+ * still claims first — ground is only ever undecided when nothing of
815
+ * the root's shape holds the address — and ambiguity (two entries
816
+ * wearing one inode: a hardlink beside a rename) refuses to guess.
817
+ * So does missing evidence: an atomic save renamed away inside its
818
+ * own settle window left ground no scan ever witnessed, and the
819
+ * follow rides witnessed continuity, never clairvoyance. Anything
820
+ * else is honest absence: `root.unavailable`, identity frozen, never
821
+ * a conclusion. Returns the root's fresh row after a follow, or null
822
+ * when the ground is absent.
823
+ */
824
+ function decideUndecidedAddress(root) {
825
+ const identity = rootIdentity(root);
826
+ if (identity) {
827
+ const wantDir = root.kind !== 'file';
828
+ const parent = path.dirname(root.path);
829
+ let entries = [];
830
+ try {
831
+ entries = fs.readdirSync(parent);
832
+ } catch {
833
+ entries = []; // the parent itself is gone: absence below
834
+ }
835
+ const found = [];
836
+ for (const name of entries) {
837
+ try {
838
+ const stat = fs.lstatSync(path.join(parent, name));
839
+ if (stat.isDirectory() === wantDir && stat.dev === identity.device && stat.ino === identity.inode) {
840
+ found.push(path.join(parent, name));
841
+ }
842
+ } catch { /* raced away mid-look; absence decides below */ }
843
+ }
844
+ if (found.length === 1) return rebindRoot(root, found[0]);
845
+ }
846
+ markAbsent(root);
847
+ return null;
848
+ }
849
+
850
+ /**
851
+ * Watcher death is an event: install a fresh doorbell, then reconcile the
852
+ * gap once. A replacement that cannot even be born stays degraded (visible
853
+ * in status()) until the next lifecycle moment retries — no timers.
854
+ */
855
+ function reviveWatcher(root) {
856
+ const state = live.get(root.rootId);
857
+ if (!state || !started) return;
858
+ state.watcher = openWatcher(root);
859
+ try {
860
+ scanNow(root.rootId);
861
+ } catch (error) {
862
+ console.warn(`[Observer] post-revive scan failed for "${root.path}":`, error?.message || error);
863
+ }
864
+ }
865
+
866
+ function startWatching(root, state) {
867
+ if (!state.watcher || state.watcher.degraded) {
868
+ state.watcher?.close();
869
+ state.watcher = openWatcher(root);
870
+ }
871
+ if ((!state.addressWatcher || state.addressWatcher.degraded)
872
+ && root.kind !== 'file' && !enclosed(root)) {
873
+ state.addressWatcher?.close();
874
+ state.addressWatcher = openAddressWatcher(root);
875
+ }
876
+ }
877
+
878
+ function attach(root, { watch = true } = {}) {
879
+ const existing = live.get(root.rootId);
880
+ if (existing) {
881
+ if (started && watch) startWatching(root, existing);
882
+ return existing;
883
+ }
884
+ const state = {
885
+ kind: root.kind,
886
+ watcher: null,
887
+ addressWatcher: null,
888
+ settleTimer: null,
889
+ settleDue: 0,
890
+ retryMs: settleMs,
891
+ dirtyPaths: new Set(),
892
+ checkAll: false,
893
+ territories: new Set(),
894
+ };
895
+ for (const child of childRepoRoots(root)) {
896
+ state.territories.add(child.path.slice(root.path.length + 1));
897
+ }
898
+ live.set(root.rootId, state);
899
+ if (started && watch) startWatching(root, state);
900
+ return state;
901
+ }
902
+
903
+ function detach(rootId) {
904
+ const state = live.get(rootId);
905
+ if (!state) return;
906
+ state.watcher?.close();
907
+ state.addressWatcher?.close();
908
+ if (state.settleTimer) clearTimeout(state.settleTimer);
909
+ live.delete(rootId);
910
+ }
911
+
912
+ return {
913
+ /**
914
+ * Enrollment names ground by ADDRESS: the parent chain is resolved so
915
+ * the address is canonical, but the last segment is inspected, never
916
+ * traversed — a symlink enrolled explicitly enrolls AS the pointer,
917
+ * the smallest root of the pointer kind (only real ground ever
918
+ * arrives here through edge-following, which resolves first).
919
+ * Identity is permanent and the address is not: ground that moved out
920
+ * from under a known root is rescued by physical continuity — the
921
+ * root keeps its rootId, every channel inside keeps its identity, and
922
+ * `root.moved` carries the news.
923
+ */
924
+ enrollRoot(rootPath, { kind, scan = true, watch = true } = {}) {
925
+ const parent = fs.realpathSync(path.dirname(rootPath));
926
+ const address = path.join(parent, path.basename(rootPath));
927
+ let stat = fs.lstatSync(address);
928
+ const resolved = stat.isSymbolicLink() ? address : fs.realpathSync(address);
929
+ if (!stat.isSymbolicLink() && resolved !== address) stat = fs.lstatSync(resolved);
930
+ const existing = store.rootByPath(resolved);
931
+ if (existing) {
932
+ // A known address wearing a different directory identity is a
933
+ // frozen reoccupation (see addressUndecided) — and THIS is the
934
+ // moment that resolves it: presence proved nothing, but naming
935
+ // the address is a deliberate act, so the root adopts the ground
936
+ // and a full scan turns the frozen beliefs into honest evidence.
937
+ // (Single-ground roots need no adoption — their address lawfully
938
+ // churns identity and the file laws absorb it at the next scan.)
939
+ if (existing.kind !== 'file' && stat.isDirectory() && existing.device != null
940
+ && (stat.dev !== existing.device || stat.ino !== existing.inode)) {
941
+ store.setRootIdentity(existing.rootId, stat.dev, stat.ino);
942
+ attach(existing, { watch });
943
+ if (scan) scanNow(existing.rootId);
944
+ return store.rootByPath(resolved);
945
+ }
946
+ attach(existing, { watch });
947
+ return existing;
948
+ }
949
+ const moved = findMovedRoot(stat);
950
+ if (moved) return rebindRoot(moved, resolved);
951
+ // Territory is decided by disk: a directory with a .git is a repo,
952
+ // a plain directory is files ground, and a file or pointer is the
953
+ // smallest root there is — one channel. Disk must actually answer:
954
+ // a kind is a durable conclusion, so unsettled ground refuses
955
+ // loudly instead of guessing — the caller's dirty/retry rule owns
956
+ // the next attempt, and no wrongly typed root is ever minted.
957
+ const territory = (kind || !stat.isDirectory()) ? null : gitDirTruth(resolved);
958
+ if (territory === 'unsettled') {
959
+ throw new Error(`cannot enroll ${resolved}: the ground could not be read, so its kind is unsettled — enroll again when a look settles`);
960
+ }
961
+ const resolvedKind = kind
962
+ ?? (!stat.isDirectory() ? 'file' : territory === 'present' ? 'repo' : 'files');
963
+ const root = {
964
+ rootId: crypto.randomUUID(), path: resolved, kind: resolvedKind,
965
+ device: stat.dev, inode: stat.ino,
966
+ };
967
+ store.addRoot(root);
968
+ attach(root, { watch });
969
+ if (!scan) return root;
970
+ if (resolvedKind === 'repo') {
971
+ publish({ type: 'repo.discovered', rootId: root.rootId, path: root.path });
972
+ censusRepoRoot(root); // enrollment is also the birth of child territories
973
+ } else if (resolvedKind === 'file') {
974
+ scanFileRoot(root); // Enrollment is the birth of the one channel.
975
+ } else {
976
+ scanFilesRoot(root); // Enrollment is the birth of channels.
977
+ }
978
+ return root;
979
+ },
980
+
981
+ removeRoot(rootId) {
982
+ detach(rootId);
983
+ store.removeRoot(rootId);
984
+ },
985
+
986
+ listRoots: () => store.listRoots(),
987
+ filesForRoot: (rootId) => store.filesForRoot(rootId),
988
+ dirsForRoot: (rootId) => store.dirsForRoot(rootId),
989
+ linksForRoot: (rootId) => store.linksForRoot(rootId),
990
+
991
+ /**
992
+ * Every enrolled root exposes its symlink edges, whatever its kind —
993
+ * the enrollment-policy surface (edges.js) reads THIS, never the
994
+ * tables directly. Files ground answers from detected truth (its
995
+ * links are channels — a single-ground root holding a pointer
996
+ * included: its one edge is the pointer itself); repo ground answers
997
+ * from a fresh worktree census. Its changes travel as one Card +
998
+ * Checkpoint state; stored child rows are refreshed only after that
999
+ * state settles, never by this raw edge query. The answer is
1000
+ * three-state: `unsettled` names ground the census could not read —
1001
+ * an edge may hide there, so the surface never claims completeness
1002
+ * it did not earn.
1003
+ */
1004
+ linkEdges(rootId) {
1005
+ const root = store.listRoots().find((row) => row.rootId === rootId);
1006
+ if (!root) return { edges: [], unsettled: [] };
1007
+ if (root.kind === 'repo') {
1008
+ const { links, unsettled } = censusLinks(root.path, shouldEnroll);
1009
+ return { edges: links, unsettled };
1010
+ }
1011
+ return {
1012
+ edges: store.linksForRoot(rootId).map(({ relPath, target }) => ({ relPath, target })),
1013
+ unsettled: [],
1014
+ };
1015
+ },
1016
+
1017
+ /**
1018
+ * The one enrollment policy, exposed so EVERY route that expands
1019
+ * coverage — walks, censuses, edge-following, the registration
1020
+ * boundary's explicit add — asks the same rule, about the COMPLETE
1021
+ * candidate path: the policy excludes by path segment (.ssh,
1022
+ * node_modules, ...), so a benign final name inside excluded ground
1023
+ * is still excluded. Walks ask with tree-relative paths, external
1024
+ * candidates with their whole address. No route may enroll ground
1025
+ * the policy excludes; an explicit override is a future deliberate
1026
+ * operation, never a default.
1027
+ */
1028
+ shouldEnroll: (candidatePath) => shouldEnroll(candidatePath),
1029
+ /**
1030
+ * Reconcile registered Git worktree children after Repocard has settled
1031
+ * a new Card + Checkpoint. This is deliberately NOT called by raw
1032
+ * filesystem doorbells: Git state is the filter that authorizes this
1033
+ * ordinary structural comparison. The root .git is skipped while nested
1034
+ * repositories remain their own territories.
1035
+ */
1036
+ reconcileRepoWorktree(rootId) {
1037
+ const root = store.listRoots().find((row) => row.rootId === rootId);
1038
+ if (!root || root.kind !== 'repo') {
1039
+ throw new Error(`reconcileRepoWorktree requires a live repo root: ${rootId}`);
1040
+ }
1041
+ scanFilesRoot(root, { allowRootRepository: true });
1042
+ },
1043
+ scanNow,
1044
+
1045
+ // The referee (verify.js): re-hash every enrolled byte, report every
1046
+ // divergence — read-only, and deliberately none of the machinery above.
1047
+ verify: createVerify({ store, shouldEnroll }),
1048
+
1049
+ /**
1050
+ * A writer that changed disk itself records the result here so the next
1051
+ * scan finds silence instead of manufacturing a change (axiom 4).
1052
+ */
1053
+ noteWrite(absPath) {
1054
+ const resolved = fs.realpathSync(absPath);
1055
+ for (const root of store.listRoots()) {
1056
+ if (root.kind === 'file' && resolved === root.path) {
1057
+ let stat;
1058
+ try {
1059
+ stat = fs.lstatSync(resolved);
1060
+ } catch {
1061
+ return;
1062
+ }
1063
+ const hashedAtMs = Date.now(); // stamped BEFORE reading, like every hash here
1064
+ const content = safeContent(resolved);
1065
+ if (content === null) return;
1066
+ const fields = {
1067
+ relPath: '', device: stat.dev, inode: stat.ino, size: stat.size,
1068
+ mtimeMs: Math.floor(stat.mtimeMs), ...content, hashedAtMs,
1069
+ };
1070
+ const row = store.fileByPath(root.rootId, '');
1071
+ if (row) store.updateFile(row.fileId, fields);
1072
+ else store.insertFile({ fileId: crypto.randomUUID(), rootId: root.rootId, ...fields });
1073
+ return;
1074
+ }
1075
+ if (root.kind !== 'files') continue;
1076
+ const rel = path.relative(root.path, resolved);
1077
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) continue;
1078
+ const relPath = rel.split(path.sep).join('/');
1079
+ const state = live.get(root.rootId);
1080
+ if (state && underTerritory(state, relPath)) return; // repo territory: not a channel
1081
+ let stat;
1082
+ try {
1083
+ stat = fs.lstatSync(resolved);
1084
+ } catch {
1085
+ return;
1086
+ }
1087
+ const hashedAtMs = Date.now();
1088
+ const content = safeContent(resolved);
1089
+ if (content === null) return;
1090
+ const fields = {
1091
+ relPath,
1092
+ device: stat.dev,
1093
+ inode: stat.ino,
1094
+ size: stat.size,
1095
+ mtimeMs: Math.floor(stat.mtimeMs),
1096
+ ...content,
1097
+ hashedAtMs,
1098
+ };
1099
+ const row = store.fileByPath(root.rootId, relPath);
1100
+ if (row) store.updateFile(row.fileId, fields);
1101
+ else store.insertFile({ fileId: crypto.randomUUID(), rootId: root.rootId, ...fields });
1102
+ return;
1103
+ }
1104
+ },
1105
+
1106
+ start(rootId = null) {
1107
+ const firstStart = !started;
1108
+ if (firstStart) started = true;
1109
+ if (!firstStart && rootId === null) return;
1110
+ const roots = rootId === null
1111
+ ? store.listRoots()
1112
+ : store.listRoots().filter((root) => root.rootId === rootId);
1113
+ // Watch begins after registration. The initial scan makes the fresh
1114
+ // structural tree concrete; changes during registration are outside
1115
+ // this operation's contract and are not reconstructed here.
1116
+ for (const root of roots) attach(root, { watch: true });
1117
+ try {
1118
+ for (const root of roots) scanNow(root.rootId);
1119
+ } catch (error) {
1120
+ console.warn('[Observer] initial scan failed:', error?.message || error);
1121
+ }
1122
+ },
1123
+
1124
+ stop() {
1125
+ started = false;
1126
+ for (const rootId of [...live.keys()]) detach(rootId);
1127
+ },
1128
+
1129
+ status() {
1130
+ return store.listRoots().map((root) => {
1131
+ const state = live.get(root.rootId);
1132
+ return {
1133
+ ...root,
1134
+ watching: Boolean(state?.watcher) && !state.watcher.degraded,
1135
+ degraded: Boolean(state?.watcher?.degraded),
1136
+ addressRequired: root.kind !== 'file' && !enclosed(root),
1137
+ addressWatching: Boolean(state?.addressWatcher) && !state.addressWatcher.degraded,
1138
+ addressDegraded: Boolean(state?.addressWatcher?.degraded),
1139
+ files: root.kind === 'repo' ? null : store.filesForRoot(root.rootId).length,
1140
+ };
1141
+ });
1142
+ },
1143
+ };
1144
+ }
1145
+
1146
+ module.exports = { createObserver };