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,200 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The referee for the INCREMENTAL detector: re-read enrolled ground
5
+ * from scratch and report every divergence from what the store
6
+ * believes. It shares no part of the path it judges — no stats
7
+ * shortcut, no doorbells, no dirty sets, no compare functions, no
8
+ * applier — so a belief the scan reached by shortcut is never
9
+ * confirmed by that same shortcut.
10
+ *
11
+ * It does share detection's PRIMITIVES: the same enrollment policy,
12
+ * the same walk, the same truth adapter. That is deliberate — one
13
+ * definition of each, none left to drift — and it bounds the verdict
14
+ * exactly: this referee grades the machinery, never the primitives it
15
+ * asks. Grading those belongs to the oracle
16
+ * (tests/entity-oracle/referee.js), which enumerates and hashes with
17
+ * its own code and none of this layer's, driven by the storms.
18
+ *
19
+ * Read-only: no events, no store writes. An empty result means no
20
+ * belief diverged from the ground the primitives reported. Ground
21
+ * that could not be read reports as an `unsettled:` entry —
22
+ * inconclusive there, never clean and never a divergence.
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ const { censusBoundaries, gitDirTruth, walkRoot } = require('./scan');
29
+ const { adapterFor } = require('../adapters');
30
+ const { lookFailed } = require('../adapters/truth');
31
+
32
+ // The same socket the observer reads: one three-state answer.
33
+ const fileTruth = adapterFor('file.binary').rails.detect.truth;
34
+
35
+ const UNSETTLED = 'unsettled: the ground could not be read — verification is inconclusive here';
36
+
37
+ function createVerify({ store, shouldEnroll }) {
38
+ return function verify(rootId) {
39
+ const mismatches = [];
40
+ for (const root of store.listRoots()) {
41
+ if (rootId && root.rootId !== rootId) continue;
42
+ if (root.kind === 'repo') {
43
+ // Equality both ways: a registered territory whose .git left disk
44
+ // is as much a divergence as an unregistered one on disk — and a
45
+ // territory that could not be inspected is neither.
46
+ const territory = gitDirTruth(root.path);
47
+ if (territory === 'unsettled') {
48
+ mismatches.push({ rootId: root.rootId, path: '.', problem: UNSETTLED });
49
+ continue;
50
+ }
51
+ if (territory === 'absent') {
52
+ mismatches.push({ rootId: root.rootId, path: '.', problem: 'ghost territory: the observer believes in a repository disk no longer has' });
53
+ continue;
54
+ }
55
+ // The census referee: every `.git` boundary on disk must have a
56
+ // registered claimant. Grandchildren are checked when their own
57
+ // parent root comes through this loop. Ground the census could
58
+ // not list is inconclusive — it may hide a boundary.
59
+ const census = censusBoundaries(root.path, shouldEnroll);
60
+ for (const relDir of census.unsettled) {
61
+ mismatches.push({ rootId: root.rootId, path: relDir || '.', problem: UNSETTLED });
62
+ }
63
+ for (const relDir of census.boundaries) {
64
+ const claimant = store.rootByPath(path.join(root.path, relDir));
65
+ if (!claimant || claimant.kind !== 'repo') {
66
+ mismatches.push({ rootId: root.rootId, path: relDir, problem: 'unclaimed territory: disk has a repository the observer never registered' });
67
+ }
68
+ }
69
+ continue;
70
+ }
71
+ if (root.kind === 'file') {
72
+ // The referee for the smallest ground: one path, one truth —
73
+ // and disk decides the truth's kind, exactly like the scan.
74
+ const row = store.fileByPath(root.rootId, '');
75
+ const linkRow = store.linksForRoot(root.rootId)[0] ?? null;
76
+ let stat = null;
77
+ try {
78
+ stat = fs.lstatSync(root.path);
79
+ } catch (error) {
80
+ if (lookFailed(error).truth === 'unsettled') {
81
+ mismatches.push({ rootId: root.rootId, path: '.', problem: UNSETTLED });
82
+ continue;
83
+ }
84
+ stat = null;
85
+ }
86
+ if (stat && stat.isFile()) {
87
+ const read = fileTruth(root.path);
88
+ if (read.truth === 'unsettled') {
89
+ mismatches.push({ rootId: root.rootId, path: '.', problem: UNSETTLED });
90
+ } else if (read.truth === 'present' && read.state.kind === 'file') {
91
+ if (!row) {
92
+ mismatches.push({ rootId: root.rootId, path: '.', problem: 'unknown: disk has a file the observer never saw' });
93
+ } else if (row.contentHash !== read.state.sha256) {
94
+ mismatches.push({ rootId: root.rootId, path: '.', problem: 'stale: disk moved, the observer did not notice' });
95
+ }
96
+ }
97
+ if (linkRow) {
98
+ mismatches.push({ rootId: root.rootId, path: '.', problem: 'ghost: the observer believes in a link disk no longer has' });
99
+ }
100
+ } else if (stat && stat.isSymbolicLink()) {
101
+ let target = null;
102
+ try {
103
+ target = fs.readlinkSync(root.path);
104
+ } catch (error) {
105
+ if (lookFailed(error).truth === 'unsettled') {
106
+ mismatches.push({ rootId: root.rootId, path: '.', problem: UNSETTLED });
107
+ continue;
108
+ }
109
+ target = null; // vanished mid-verify
110
+ }
111
+ if (target !== null) {
112
+ if (!linkRow) {
113
+ mismatches.push({ rootId: root.rootId, path: '.', problem: 'unknown: disk has a link the observer never saw' });
114
+ } else if (linkRow.target !== target) {
115
+ mismatches.push({ rootId: root.rootId, path: '.', problem: 'stale: the link was retargeted, the observer did not notice' });
116
+ }
117
+ }
118
+ if (row) {
119
+ mismatches.push({ rootId: root.rootId, path: '.', problem: 'ghost: the observer believes in a file disk no longer has' });
120
+ }
121
+ } else {
122
+ if (row) {
123
+ mismatches.push({ rootId: root.rootId, path: '.', problem: 'ghost: the observer believes in a file disk no longer has' });
124
+ }
125
+ if (linkRow) {
126
+ mismatches.push({ rootId: root.rootId, path: '.', problem: 'ghost: the observer believes in a link disk no longer has' });
127
+ }
128
+ }
129
+ continue;
130
+ }
131
+ if (root.kind !== 'files') continue;
132
+ const rows = new Map(store.filesForRoot(root.rootId).map((row) => [row.relPath, row]));
133
+ const { files, dirs, links, repoDirs, unsettled } = walkRoot(root.path, shouldEnroll);
134
+ // Ground the walk could not read is inconclusive — one entry per
135
+ // unsettled path, and NO conclusion beneath one: not clean, not
136
+ // ghost.
137
+ const underUnsettled = (relPath) =>
138
+ unsettled.some((dir) => dir === '' || relPath === dir || relPath.startsWith(`${dir}/`));
139
+ for (const relPath of unsettled) {
140
+ mismatches.push({ rootId: root.rootId, path: relPath || '.', problem: UNSETTLED });
141
+ }
142
+ const linkRows = new Map(store.linksForRoot(root.rootId).map((row) => [row.relPath, row]));
143
+ for (const entry of links) {
144
+ const row = linkRows.get(entry.relPath);
145
+ if (!row) {
146
+ mismatches.push({ rootId: root.rootId, path: entry.relPath, problem: 'unknown: disk has a link the observer never saw' });
147
+ } else if (row.target !== entry.target) {
148
+ mismatches.push({ rootId: root.rootId, path: entry.relPath, problem: 'stale: the link was retargeted, the observer did not notice' });
149
+ }
150
+ linkRows.delete(entry.relPath);
151
+ }
152
+ for (const relPath of linkRows.keys()) {
153
+ if (underUnsettled(relPath)) continue;
154
+ mismatches.push({ rootId: root.rootId, path: relPath, problem: 'ghost: the observer believes in a link disk no longer has' });
155
+ }
156
+ const dirRows = new Set(store.dirsForRoot(root.rootId).map((row) => row.relPath));
157
+ for (const entry of dirs) {
158
+ if (!dirRows.delete(entry.relPath)) {
159
+ mismatches.push({ rootId: root.rootId, path: entry.relPath, problem: 'unknown: disk has a directory the observer never saw' });
160
+ }
161
+ }
162
+ for (const relPath of dirRows) {
163
+ if (underUnsettled(relPath)) continue;
164
+ mismatches.push({ rootId: root.rootId, path: relPath, problem: 'ghost: the observer believes in a directory disk no longer has' });
165
+ }
166
+ for (const relDir of repoDirs) {
167
+ const claimant = store.rootByPath(relDir ? path.join(root.path, relDir) : root.path);
168
+ if (!claimant || claimant.kind !== 'repo') {
169
+ mismatches.push({ rootId: root.rootId, path: relDir || '.', problem: 'unclaimed territory: disk has a repository the observer never registered' });
170
+ }
171
+ }
172
+ for (const entry of files) {
173
+ const read = fileTruth(path.join(root.path, entry.relPath));
174
+ if (read.truth === 'unsettled') {
175
+ mismatches.push({ rootId: root.rootId, path: entry.relPath, problem: UNSETTLED });
176
+ rows.delete(entry.relPath); // inconclusive, not ghost
177
+ continue;
178
+ }
179
+ if (read.truth !== 'present' || read.state.kind !== 'file') {
180
+ rows.delete(entry.relPath); // vanished mid-verify: a race, not a conclusion
181
+ continue;
182
+ }
183
+ const row = rows.get(entry.relPath);
184
+ if (!row) {
185
+ mismatches.push({ rootId: root.rootId, path: entry.relPath, problem: 'unknown: disk has a file the observer never saw' });
186
+ } else if (row.contentHash !== read.state.sha256) {
187
+ mismatches.push({ rootId: root.rootId, path: entry.relPath, problem: 'stale: disk moved, the observer did not notice' });
188
+ }
189
+ rows.delete(entry.relPath);
190
+ }
191
+ for (const row of rows.values()) {
192
+ if (underUnsettled(row.relPath)) continue;
193
+ mismatches.push({ rootId: root.rootId, path: row.relPath, problem: 'ghost: the observer believes in a file disk no longer has' });
194
+ }
195
+ }
196
+ return mismatches;
197
+ };
198
+ }
199
+
200
+ module.exports = { createVerify };
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The doorbell half of detection: one recursive OS watcher per root.
5
+ * macOS = FSEvents, Windows = ReadDirectoryChangesW, Linux = inotify
6
+ * (recursive support requires Node >= 20, which the engine already requires).
7
+ *
8
+ * A watcher is allowed to be lossy and allowed to die — but death is an
9
+ * event like any other: `onDead` fires exactly once so the owner can
10
+ * rebuild the doorbell and reconcile the gap. A watcher that cannot even
11
+ * be born returns already `degraded` and never calls `onDead`. Nothing
12
+ * here throws past this module.
13
+ *
14
+ * A handle is { degraded, close }. The watcher never mutates the ground
15
+ * it observes: its only job is to carry filesystem doorbells to its owner.
16
+ */
17
+
18
+ const fs = require('fs');
19
+ function install(dirPath, recursive, onDirty, onDead) {
20
+ const handle = { degraded: false, close: () => {} };
21
+ let watcher = null;
22
+ try {
23
+ watcher = fs.watch(dirPath, { recursive, persistent: false }, (_event, filename) => {
24
+ onDirty(filename ? String(filename).split('\\').join('/') : null);
25
+ });
26
+ } catch {
27
+ handle.degraded = true;
28
+ return handle;
29
+ }
30
+ watcher.on('error', () => {
31
+ if (handle.degraded) return; // dead is dead: one death, one report
32
+ handle.degraded = true;
33
+ try { watcher.close(); } catch { /* already dead */ }
34
+ if (onDead) onDead();
35
+ });
36
+ handle.close = () => {
37
+ try { watcher.close(); } catch { /* already dead */ }
38
+ };
39
+ return handle;
40
+ }
41
+
42
+ function watchRecursive(rootPath, onDirty, onDead) {
43
+ return install(rootPath, true, onDirty, onDead);
44
+ }
45
+
46
+ /**
47
+ * The non-recursive doorbell: one directory, every event, the raw name
48
+ * delivered (or null, which could be anything). It hears EVERYTHING —
49
+ * filtering is the consumer's business, because a delivered name proves
50
+ * nothing about what it means: a rename of A to B may lawfully deliver
51
+ * only "B", and a doorbell that filters for "A" has decided the answer
52
+ * before looking. Two consumers ride it: a single-file root's main
53
+ * doorbell (the file's own inode goes deaf on atomic saves; the parent
54
+ * directory does not) and every top-level root's address doorbell (the
55
+ * parent observes address changes; the entity observes content
56
+ * changes). Same death contract as watchRecursive.
57
+ */
58
+ function watchDir(dirPath, onDirty, onDead) {
59
+ return install(dirPath, false, onDirty, onDead);
60
+ }
61
+
62
+ module.exports = { watchDir, watchRecursive };
@@ -16,9 +16,9 @@
16
16
  * system-instructions.md global user-authored instructions under AMALGM_DIR
17
17
  *
18
18
  * Direct disk edits (editors, agents with their own file tools) are picked up
19
- * by the per-project fs watcher, a bounded verifier for notifications the OS
20
- * may drop, and the fs REST hooks. All three funnel into the same reconcile
21
- * path. Reconcile is idempotent: unchanged revisions emit no events.
19
+ * by the per-project fs watcher and the fs REST hooks, which funnel into the
20
+ * same reconcile path. Reconcile is idempotent: unchanged revisions emit no
21
+ * events.
22
22
  *
23
23
  * Everything that emits events runs in the amalgm-mcp process — Local Live
24
24
  * only streams events appended in-process. chat-core (chat-server process)
@@ -56,13 +56,11 @@ const {
56
56
 
57
57
  const RESOURCE = 'project_context';
58
58
  const WATCH_DEBOUNCE_MS = 200;
59
- const WATCH_VERIFY_MS = 5_000;
60
59
  // The system scope carries user-authored instructions that apply to every
61
60
  // session on this computer. It has no change log or project state.
62
61
  const SYSTEM_CONTEXT_ID = 'system';
63
62
 
64
- const watchers = new Map(); // projectId -> { watcher, timer, project, projectPath }
65
- let watchVerificationTimer = null;
63
+ const watchers = new Map(); // projectId -> { watcher, timer, projectPath }
66
64
 
67
65
  function systemProject() {
68
66
  return { id: SYSTEM_CONTEXT_ID, path: path.resolve(AMALGM_DIR), name: 'Amalgm System' };
@@ -685,50 +683,12 @@ function closeWatcher(projectId) {
685
683
  const entry = watchers.get(projectId);
686
684
  if (!entry) return;
687
685
  if (entry.timer) clearTimeout(entry.timer);
688
- if (entry.watcher) {
689
- try {
690
- entry.watcher.close();
691
- } catch {
692
- // Already closed.
693
- }
686
+ try {
687
+ entry.watcher.close();
688
+ } catch {
689
+ // Already closed.
694
690
  }
695
691
  watchers.delete(projectId);
696
- if (watchers.size === 0 && watchVerificationTimer) {
697
- clearInterval(watchVerificationTimer);
698
- watchVerificationTimer = null;
699
- }
700
- }
701
-
702
- /**
703
- * `fs.watch` is a latency hint, not durable evidence: kernels may coalesce or
704
- * drop notifications. This bounded pass owns that wait and makes every
705
- * watched context converge even when no callback arrives.
706
- */
707
- function verifyProjectContextWatchers() {
708
- for (const entry of watchers.values()) {
709
- try {
710
- reconcileProjectContext(entry.project, { source: 'project-context:verify' });
711
- if (!entry.watcher) {
712
- entry.watcher = openProjectContextWatcher(entry.project);
713
- }
714
- } catch (error) {
715
- const message = error instanceof Error ? error.message : String(error);
716
- console.warn(
717
- `[ProjectContext] Bounded verify failed for ${entry.projectPath}: ${message}`,
718
- );
719
- }
720
- }
721
- }
722
-
723
- function ensureWatchVerification() {
724
- if (watchVerificationTimer || watchers.size === 0) return;
725
- watchVerificationTimer = setInterval(
726
- verifyProjectContextWatchers,
727
- WATCH_VERIFY_MS,
728
- );
729
- if (typeof watchVerificationTimer.unref === 'function') {
730
- watchVerificationTimer.unref();
731
- }
732
692
  }
733
693
 
734
694
  /**
@@ -737,7 +697,13 @@ function ensureWatchVerification() {
737
697
  * harmless: by the time the debounce fires, disk matches the row and no event
738
698
  * is emitted.
739
699
  */
740
- function openProjectContextWatcher(project) {
700
+ function watchProjectContext(input) {
701
+ const project = projectFromInput(input);
702
+ if (!project?.id || !project?.path) return;
703
+ const existing = watchers.get(project.id);
704
+ if (existing?.projectPath === project.path) return;
705
+ if (existing) closeWatcher(project.id);
706
+
741
707
  const root = contextPathsFor(project).root;
742
708
  let watcher;
743
709
  try {
@@ -757,46 +723,12 @@ function openProjectContextWatcher(project) {
757
723
  if (typeof entry.timer.unref === 'function') entry.timer.unref();
758
724
  });
759
725
  } catch {
760
- // The bounded verifier remains the durable fallback and retries this hint.
761
- return null;
762
- }
763
- watcher.on('error', () => {
764
- const entry = watchers.get(project.id);
765
- if (!entry || entry.watcher !== watcher) return;
766
- try {
767
- watcher.close();
768
- } catch {
769
- // Already closed.
770
- }
771
- entry.watcher = null;
772
- });
773
- if (typeof watcher.unref === 'function') watcher.unref();
774
- return watcher;
775
- }
776
-
777
- function watchProjectContext(input) {
778
- const project = projectFromInput(input);
779
- if (!project?.id || !project?.path) return;
780
- const existing = watchers.get(project.id);
781
- if (existing?.projectPath === project.path) {
782
- existing.project = project;
783
- if (!existing.watcher) {
784
- existing.watcher = openProjectContextWatcher(project);
785
- }
786
- ensureWatchVerification();
726
+ // Context dir missing or unwatchable — best effort; REST hooks still cover writes.
787
727
  return;
788
728
  }
789
- if (existing) closeWatcher(project.id);
790
-
791
- const entry = {
792
- watcher: null,
793
- timer: null,
794
- project,
795
- projectPath: project.path,
796
- };
797
- watchers.set(project.id, entry);
798
- entry.watcher = openProjectContextWatcher(project);
799
- ensureWatchVerification();
729
+ watcher.on('error', () => closeWatcher(project.id));
730
+ if (typeof watcher.unref === 'function') watcher.unref();
731
+ watchers.set(project.id, { watcher, timer: null, projectPath: project.path });
800
732
  }
801
733
 
802
734
  /** fs REST hook: reconcile when a context doc is touched outside the store. */
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Amalgm's file-type policy: which physical type a piece of file ground
5
+ * takes. Classification is an INJECTED policy everywhere it is used
6
+ * (registration and its lenses refuse to construct without one), so it
7
+ * lives in its own file with no other job — one definition, replaceable
8
+ * whole.
9
+ *
10
+ * The rule is the oldest one there is: bytes holding a NUL are not text.
11
+ * What matters is WHERE that fact comes from. It is not read here.
12
+ * Detect's single read of a file produces its fingerprint and this
13
+ * report together (adapters/filesystem.js), and this policy only names
14
+ * the type that report implies.
15
+ *
16
+ * That is the whole law: A FILE'S HASH AND ITS TYPE DESCRIBE THE SAME
17
+ * OBSERVED BYTES. A policy that opened the ground itself would be a
18
+ * second look — and a second look can meet different bytes, or none at
19
+ * all, leaving a record whose type contradicts the payload beside it.
20
+ * So this function takes evidence, never a path; it is total, and it
21
+ * cannot fail.
22
+ *
23
+ * Both types travel as exact versions today; the distinction reserves
24
+ * `file.text` for the richer merge behavior it earns when Send exists
25
+ * (docs/realtime-rails.md §1).
26
+ */
27
+
28
+ /** @param {{ binary: boolean }} evidence — Detect's report on the bytes it hashed. */
29
+ function classifyFile({ binary }) {
30
+ return binary ? 'file.binary' : 'file.text';
31
+ }
32
+
33
+ module.exports = { classifyFile };