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
@@ -1,21 +1,33 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Ordered file-state application.
4
+ * The coexistence engine.
5
5
  *
6
- * Every official transition changes a tree from one exact state to another.
7
- * Transitions are applied in rail order. Different paths combine; when two
8
- * official transitions touch the same path, the later transition wins.
6
+ * The workspace rule: every writer — human, agent, or remote machine —
7
+ * writes into the same working tree, and no writer's work is ever
8
+ * silently lost. This module is the mechanism. A peer's contribution to
9
+ * a local working tree is represented as a git tree oid ("base commit
10
+ * plus exactly the peer content folded in so far"), and the only verb is
11
+ * advance: move that contribution from the tree we hold to the tree the
12
+ * peer just published, file by file, around whatever else lives in the
13
+ * working tree.
9
14
  *
10
- * The visible working tree may also contain a pending local write. Applying
11
- * an earlier official transition advances the confirmed tree underneath that
12
- * write without replacing its visible bytes. The pending write is published
13
- * later from the new confirmed state.
15
+ * Per file, in order:
16
+ * - working file already matches the peer's version → fold without
17
+ * writing (this is what makes echoes and replays no-ops);
18
+ * - working file untouched since the held version → take the peer's
19
+ * version verbatim (create, update, or delete);
20
+ * - both sides changed text → content-level three-way merge; a clean
21
+ * merge writes, an overlapping one holds the file aside untouched
22
+ * with a named conflict kind. Both versions survive: local in the
23
+ * tree, the peer's in their checkpoint.
14
24
  *
15
- * Everything computes against the object database with scratch index files;
16
- * the only worktree mutation is the final ordered write set.
25
+ * Everything here computes against the object database with scratch
26
+ * index files; the only worktree mutation is the final write set, which
27
+ * goes through transition.applyMergedFiles so it lands in the journal.
17
28
  */
18
29
 
30
+ const crypto = require('crypto');
19
31
  const fs = require('fs');
20
32
  const os = require('os');
21
33
  const path = require('path');
@@ -72,15 +84,6 @@ async function hasTree(worktreeRoot, treeOid) {
72
84
  return result.exitCode === 0;
73
85
  }
74
86
 
75
- /** Gitlinks are a different repository and are not a file state we can apply. */
76
- async function gitlinksInTree(worktreeRoot, treeOid) {
77
- const { stdout } = await run(['ls-tree', '-r', '-z', treeOid], worktreeRoot);
78
- return stdout.split('\0').filter(Boolean).flatMap((entry) => {
79
- const match = entry.match(/^160000 commit [0-9a-f]+\t(.+)$/);
80
- return match ? [match[1]] : [];
81
- });
82
- }
83
-
84
87
  /**
85
88
  * The tree a captured worktree patch describes: base commit's tree with
86
89
  * the patch applied. Computed entirely in a scratch index — the worktree
@@ -144,7 +147,7 @@ async function readBlob(worktreeRoot, oid) {
144
147
 
145
148
  /** The working file as bytes: { kind: 'file'|'symlink', content, exec } or null. */
146
149
  function workFileState(worktreeRoot, relPath) {
147
- const abs = transition.confinedPath(worktreeRoot, relPath);
150
+ const abs = path.join(worktreeRoot, relPath);
148
151
  let stat;
149
152
  try {
150
153
  stat = fs.lstatSync(abs);
@@ -154,7 +157,7 @@ function workFileState(worktreeRoot, relPath) {
154
157
  if (stat.isSymbolicLink()) {
155
158
  return { kind: 'symlink', content: Buffer.from(fs.readlinkSync(abs)), exec: false };
156
159
  }
157
- if (!stat.isFile()) return { kind: 'other' };
160
+ if (!stat.isFile()) return null;
158
161
  return { kind: 'file', content: fs.readFileSync(abs), exec: (stat.mode & 0o100) !== 0 };
159
162
  }
160
163
 
@@ -168,84 +171,124 @@ function workMatches(work, entry, blobBytes) {
168
171
  && work.content.equals(blobBytes);
169
172
  }
170
173
 
171
- /**
172
- * Apply one official transition (`baseTree → targetTree`) to the current
173
- * confirmed tree. Every path changed by the transition takes its target
174
- * state. Rail order alone decides the winner.
175
- */
176
- async function reduceTree(worktreeRoot, currentTree, baseTree, targetTree) {
177
- const updates = [];
178
- for (const relPath of await changedPaths(worktreeRoot, baseTree, targetTree)) {
179
- if (unsafePath(relPath)) throw new Error(`unsafe path: ${relPath}`);
180
- const entry = await entryAt(worktreeRoot, targetTree, relPath);
181
- if (entry && entry.type !== 'blob') {
182
- throw new Error(`unsupported gitlink/submodule at ${relPath}`);
183
- }
184
- updates.push({ path: relPath, entry });
185
- }
174
+ function isText(buffer) {
175
+ return buffer !== null && !buffer.includes(0);
176
+ }
186
177
 
187
- return {
188
- tree: updates.length ? await updateHeldTree(worktreeRoot, currentTree, updates) : currentTree,
189
- };
178
+ /** Content-level three-way merge; { ok, content } — never writes markers to disk. */
179
+ async function mergeFileContents(ours, base, theirs, labels = {}) {
180
+ return withScratch(async (dir) => {
181
+ const oursFile = path.join(dir, 'ours');
182
+ const baseFile = path.join(dir, 'base');
183
+ const theirsFile = path.join(dir, 'theirs');
184
+ await fs.promises.writeFile(oursFile, ours);
185
+ await fs.promises.writeFile(baseFile, base);
186
+ await fs.promises.writeFile(theirsFile, theirs);
187
+ const conflictCodes = Array.from({ length: 127 }, (_, i) => i + 1);
188
+ const result = await run(
189
+ [
190
+ 'merge-file', '-p',
191
+ '-L', labels.ours || 'current',
192
+ '-L', labels.base || 'base',
193
+ '-L', labels.theirs || 'incoming',
194
+ oursFile, baseFile, theirsFile,
195
+ ],
196
+ dir,
197
+ { okCodes: conflictCodes, binary: true },
198
+ );
199
+ return { ok: result.exitCode === 0, content: result.stdout };
200
+ });
190
201
  }
191
202
 
192
203
  function sameEntry(left, right) {
193
- return (
194
- left === null
204
+ return left === null
195
205
  ? right === null
196
- : right !== null
197
- && left.mode === right.mode
198
- && left.type === right.type
199
- && left.oid === right.oid
200
- );
206
+ : right !== null && left.mode === right.mode && left.type === right.type && left.oid === right.oid;
207
+ }
208
+
209
+ async function storedBlob(worktreeRoot, content, scratchDir, name) {
210
+ const file = path.join(scratchDir, name);
211
+ await fs.promises.writeFile(file, content);
212
+ const { stdout } = await run(['hash-object', '-w', file], worktreeRoot);
213
+ return stdout.trim();
214
+ }
215
+
216
+ function conflictCopyPath(relPath, label) {
217
+ const digest = crypto.createHash('sha256').update(`${label}\0${relPath}`).digest('hex').slice(0, 16);
218
+ return `.amalgm-conflicts/${digest}/${relPath}`;
201
219
  }
202
220
 
203
221
  /**
204
- * Resolve a crash-time tree from an exact apply WAL.
205
- *
206
- * Values equal to WAL source or target are known official progress and land
207
- * on target. A value equal to neither is a real write made after the WAL was
208
- * sealed, so it remains on top of target.
222
+ * Pure checkpoint reduction. Apply one published transition
223
+ * (`baseTree → targetTree`) to the currently reduced tree. No working file
224
+ * is read or written. Disjoint changes combine, identical changes fold, and
225
+ * overlaps produce the same conflict bytes and paths on every machine.
209
226
  */
210
- async function reconcileApplyingTree(
211
- worktreeRoot,
212
- currentTree,
213
- sourceTree,
214
- targetTree,
215
- { sourceCrossed = false, crossedPaths = null } = {},
216
- ) {
217
- const paths = new Set([
218
- ...await changedPaths(worktreeRoot, sourceTree, currentTree),
219
- ...await changedPaths(worktreeRoot, targetTree, currentTree),
220
- ]);
221
- const local = [];
227
+ async function reduceTree(worktreeRoot, currentTree, baseTree, targetTree, { label = 'rail' } = {}) {
222
228
  const updates = [];
223
- for (const relPath of [...paths].sort()) {
224
- if (unsafePath(relPath)) throw new Error(`unsafe path: ${relPath}`);
225
- const [current, source, target] = await Promise.all([
226
- entryAt(worktreeRoot, currentTree, relPath),
227
- entryAt(worktreeRoot, sourceTree, relPath),
228
- entryAt(worktreeRoot, targetTree, relPath),
229
- ]);
230
- for (const entry of [current, source, target]) {
231
- if (entry && entry.type !== 'blob') {
232
- throw new Error(`unsupported gitlink/submodule at ${relPath}`);
229
+ const conflicts = [];
230
+
231
+ await withScratch(async (dir) => {
232
+ let stored = 0;
233
+ for (const relPath of await changedPaths(worktreeRoot, baseTree, targetTree)) {
234
+ if (unsafePath(relPath)) {
235
+ conflicts.push({ path: relPath, kind: 'unsafe-path', label });
236
+ continue;
237
+ }
238
+ const base = await entryAt(worktreeRoot, baseTree, relPath);
239
+ const current = await entryAt(worktreeRoot, currentTree, relPath);
240
+ const target = await entryAt(worktreeRoot, targetTree, relPath);
241
+
242
+ if (sameEntry(current, target) || sameEntry(base, target)) continue;
243
+ if (sameEntry(current, base)) {
244
+ updates.push({ path: relPath, entry: target });
245
+ continue;
246
+ }
247
+
248
+ const baseBytes = base?.type === 'blob' ? await readBlob(worktreeRoot, base.oid) : null;
249
+ const currentBytes = current?.type === 'blob' ? await readBlob(worktreeRoot, current.oid) : null;
250
+ const targetBytes = target?.type === 'blob' ? await readBlob(worktreeRoot, target.oid) : null;
251
+ const mergeable = base && current && target
252
+ && base.type === 'blob' && current.type === 'blob' && target.type === 'blob'
253
+ && base.mode !== '120000' && current.mode !== '120000' && target.mode !== '120000'
254
+ && isText(baseBytes) && isText(currentBytes) && isText(targetBytes);
255
+
256
+ if (mergeable) {
257
+ const merged = await mergeFileContents(currentBytes, baseBytes, targetBytes, {
258
+ ours: 'current',
259
+ base: 'shared-base',
260
+ theirs: `rail-${label}`,
261
+ });
262
+ const oid = await storedBlob(worktreeRoot, merged.content, dir, `merged-${stored++}`);
263
+ updates.push({ path: relPath, entry: { mode: current.mode, type: 'blob', oid } });
264
+ if (!merged.ok) conflicts.push({ path: relPath, kind: 'content-overlap', label });
265
+ continue;
266
+ }
267
+
268
+ // Binary/delete/add conflicts retain the current path and, when the
269
+ // incoming side has bytes, preserve those bytes at one deterministic
270
+ // conflict path. The reducer's conflict record preserves delete intent.
271
+ const kind = !target
272
+ ? 'delete-vs-change'
273
+ : !current
274
+ ? 'change-vs-delete'
275
+ : !base
276
+ ? 'add-add'
277
+ : 'binary-overlap';
278
+ const conflict = { path: relPath, kind, label };
279
+ if (targetBytes) {
280
+ const copyPath = conflictCopyPath(relPath, label);
281
+ const oid = await storedBlob(worktreeRoot, targetBytes, dir, `conflict-${stored++}`);
282
+ updates.push({ path: copyPath, entry: { mode: '100644', type: 'blob', oid } });
283
+ conflict.copyPath = copyPath;
233
284
  }
285
+ conflicts.push(conflict);
234
286
  }
235
- if (sameEntry(current, target)) continue;
236
- const crossed = sourceCrossed || crossedPaths?.has(relPath) === true;
237
- // Endpoint resemblance alone is not provenance. If the apply receipt
238
- // proves this path crossed, returning to the source value is a later
239
- // local write (including deleting an official create) and must remain.
240
- if (sameEntry(current, source) && !crossed) continue;
241
- local.push(relPath);
242
- updates.push({ path: relPath, entry: current });
243
- }
287
+ });
288
+
244
289
  return {
245
- tree: updates.length
246
- ? await updateHeldTree(worktreeRoot, targetTree, updates)
247
- : targetTree,
248
- local,
290
+ tree: updates.length ? await updateHeldTree(worktreeRoot, currentTree, updates) : currentTree,
291
+ conflicts,
249
292
  };
250
293
  }
251
294
 
@@ -275,42 +318,81 @@ function unsafePath(relPath) {
275
318
  }
276
319
 
277
320
  /**
278
- * Advance confirmed state from heldTree to targetTree. A working file that
279
- * differs from heldTree is a later pending local write, so it stays visible
280
- * while the confirmed tree advances underneath it.
321
+ * Advance the peer's contribution in this working tree from heldTree to
322
+ * targetTree. Returns { applied, conflicts, heldTree, wrote } — applied
323
+ * paths are folded into the returned heldTree; conflicted paths are left
324
+ * exactly as the working tree had them, each with a named kind.
281
325
  */
282
326
  async function advance(worktreeRoot, heldTree, targetTree, { label } = {}) {
283
327
  const applied = [];
328
+ const conflicts = [];
284
329
  const writes = [];
285
330
 
286
331
  for (const relPath of await changedPaths(worktreeRoot, heldTree, targetTree)) {
287
- if (unsafePath(relPath)) throw new Error(`unsafe path: ${relPath}`);
332
+ if (unsafePath(relPath)) {
333
+ conflicts.push({ path: relPath, kind: 'unsafe-path' });
334
+ continue;
335
+ }
288
336
  const held = await entryAt(worktreeRoot, heldTree, relPath);
289
337
  const target = await entryAt(worktreeRoot, targetTree, relPath);
290
338
  if ((held && held.type !== 'blob') || (target && target.type !== 'blob')) {
291
- throw new Error(`unsupported gitlink/submodule at ${relPath}`);
339
+ conflicts.push({ path: relPath, kind: 'unsupported-entry' });
340
+ continue;
292
341
  }
293
342
  const heldBytes = held ? await readBlob(worktreeRoot, held.oid) : null;
294
343
  const targetBytes = target ? await readBlob(worktreeRoot, target.oid) : null;
295
344
  const work = workFileState(worktreeRoot, relPath);
296
345
 
346
+ if (workMatches(work, target, targetBytes)) {
347
+ // Already there — a replayed or echoed change folds without a write.
348
+ applied.push({ path: relPath, entry: target });
349
+ continue;
350
+ }
297
351
  if (workMatches(work, held, heldBytes)) {
352
+ // Untouched locally since what we hold — take the peer's version.
298
353
  writes.push(target
299
- ? {
300
- path: relPath,
301
- content: targetBytes,
302
- mode: target.mode,
303
- expected: work,
304
- }
305
- : { path: relPath, content: null, expected: work });
354
+ ? { path: relPath, content: targetBytes, mode: target.mode }
355
+ : { path: relPath, content: null });
356
+ applied.push({ path: relPath, entry: target });
357
+ continue;
358
+ }
359
+
360
+ // The working file differs from both what we hold and what the peer
361
+ // published: two writers touched it. Merge or preserve — never pick.
362
+ if (!target) {
363
+ conflicts.push({ path: relPath, kind: 'delete-vs-edit' });
364
+ continue;
365
+ }
366
+ if (!work) {
367
+ conflicts.push({ path: relPath, kind: 'edit-vs-delete' });
368
+ continue;
369
+ }
370
+ if (!held) {
371
+ conflicts.push({ path: relPath, kind: 'add-add' });
372
+ continue;
373
+ }
374
+ const mergeable = work.kind === 'file'
375
+ && target.mode !== '120000'
376
+ && held.mode !== '120000'
377
+ && isText(work.content) && isText(heldBytes) && isText(targetBytes);
378
+ if (!mergeable) {
379
+ conflicts.push({ path: relPath, kind: 'binary-overlap' });
380
+ continue;
381
+ }
382
+ const merged = await mergeFileContents(work.content, heldBytes, targetBytes);
383
+ if (!merged.ok) {
384
+ conflicts.push({ path: relPath, kind: 'content-overlap' });
385
+ continue;
306
386
  }
387
+ writes.push({ path: relPath, content: merged.content, mode: work.exec ? '100755' : '100644' });
307
388
  applied.push({ path: relPath, entry: target });
308
389
  }
309
390
 
310
- if (writes.length) await transition.applyFiles(worktreeRoot, writes, label);
391
+ if (writes.length) await transition.applyMergedFiles(worktreeRoot, writes, label);
311
392
  const newHeldTree = applied.length ? await updateHeldTree(worktreeRoot, heldTree, applied) : heldTree;
312
393
  return {
313
394
  applied: applied.map((a) => a.path),
395
+ conflicts,
314
396
  heldTree: newHeldTree,
315
397
  wrote: writes.length,
316
398
  };
@@ -318,12 +400,9 @@ async function advance(worktreeRoot, heldTree, targetTree, { label } = {}) {
318
400
 
319
401
  module.exports = {
320
402
  advance,
321
- changedPaths,
322
403
  diffOfTree,
323
- gitlinksInTree,
324
404
  hasTree,
325
405
  indexTree,
326
- reconcileApplyingTree,
327
406
  reduceTree,
328
407
  treeOf,
329
408
  treeOfPatch,