hivecode-mcp 0.14.0 → 0.14.2

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 (72) hide show
  1. package/HIVE_RULES.md +98 -98
  2. package/LICENSE +21 -21
  3. package/README.md +421 -421
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -310
  6. package/docs/GITHUB_WORKFLOW.md +394 -394
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -157
  9. package/docs/QUICKSTART.md +266 -266
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +474 -450
  12. package/docs/TROUBLESHOOTING.md +105 -105
  13. package/hive-activity.js +336 -336
  14. package/hive-authors.js +46 -44
  15. package/hive-await.js +230 -230
  16. package/hive-branch.js +608 -607
  17. package/hive-busy.js +88 -88
  18. package/hive-coord.js +110 -110
  19. package/hive-gate.js +135 -135
  20. package/hive-github.js +2004 -1944
  21. package/hive-hook-install.js +179 -179
  22. package/hive-instructions.js +74 -74
  23. package/hive-land.js +695 -691
  24. package/hive-listen.js +81 -81
  25. package/hive-mailbox.js +520 -520
  26. package/hive-mcp-http.js +591 -580
  27. package/hive-mcp.js +2156 -2093
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +351 -350
  30. package/hive-notify.js +234 -233
  31. package/hive-obligations.js +247 -247
  32. package/hive-poll-proto.js +50 -50
  33. package/hive-pr-staleness.js +189 -189
  34. package/hive-pr.js +600 -512
  35. package/hive-privacy.js +327 -327
  36. package/hive-receipts.js +233 -233
  37. package/hive-relay-health.js +234 -230
  38. package/hive-repos.js +430 -430
  39. package/hive-room-key.js +210 -209
  40. package/hive-room-mode.js +1668 -1668
  41. package/hive-room-watcher.js +562 -561
  42. package/hive-secrets.js +89 -89
  43. package/hive-session.js +114 -114
  44. package/hive-setup.js +560 -558
  45. package/hive-slack-agent.js +408 -396
  46. package/hive-slack.js +2384 -1809
  47. package/hive-stdio.js +172 -172
  48. package/hive-stop-hook.js +293 -293
  49. package/hive-throttle.js +118 -118
  50. package/hive-time.js +71 -71
  51. package/hive-transport.js +578 -353
  52. package/hive-update.js +58 -58
  53. package/hive-version.js +110 -110
  54. package/hive-webhook.js +55 -54
  55. package/icr-merge.js +58 -58
  56. package/icr.js +851 -851
  57. package/lang-brace.js +305 -305
  58. package/lang-js.js +651 -651
  59. package/lang-json.js +211 -211
  60. package/lang-python.js +588 -588
  61. package/lang-ruby.js +230 -230
  62. package/lang-toml.js +136 -136
  63. package/lang-yaml.js +183 -183
  64. package/limits.js +413 -413
  65. package/package.json +192 -192
  66. package/reconcile.cjs +621 -614
  67. package/slack-app-manifest-agent.json +61 -61
  68. package/slack-app-manifest-inbound.json +36 -36
  69. package/slack-app-manifest.json +27 -27
  70. package/substrate.js +252 -252
  71. package/sync.js +5189 -4994
  72. package/token.js +201 -201
package/reconcile.cjs CHANGED
@@ -1,614 +1,621 @@
1
- // reconcile.cjs — Single source of truth for the 3-way reconcile & merge engine.
2
- //
3
- // CommonJS on purpose: sync.js is ESM and the VS Code extension is CJS.
4
- // A .cjs file is the one shape both can load synchronously without duplication.
5
- //
6
- // Acceptance criteria (HIV-8), re-checked 21 Sep 2026 -- FOUR OF FIVE MET:
7
- // 1. MET one module exports reconcile() and its dependencies (11 exports).
8
- // 2. MET core.js re-exports from here and sync.js imports core.js. One copy.
9
- // 3. NOT MET extension/extension.js does NOT require this file. It requires
10
- // ./icr.cjs (the ICR bundle) and keeps its OWN reconcile() at
11
- // extension/extension.js:1166. The second copy is still there.
12
- // 4. MET node tests/test.js -- 95 passed, 0 failed.
13
- // 5. MET node tests/conflict-test.mjs -- ALL 4 PASS.
14
- //
15
- // Criterion 3 read "No second copy anywhere" while a second copy existed, in a file
16
- // that ships in package.json files[]. A comment asserting an invariant it does not
17
- // hold is worse than no comment: the next reader trusts it and stops looking. Corrected
18
- // to what is true rather than deleted, because the gap is the useful part.
19
- //
20
- // WHAT ACTUALLY GUARDS THE DRIFT, since it is not this file:
21
- // tests/extension-parity-test.mjs the ICR bundle vs the engine, 24/24
22
- // tests/extension-reconcile-characterisation-test.mjs pins the extension's own copy
23
- // Those two measure that the copies AGREE. They do not make it one copy, and closing
24
- // criterion 3 for real means the extension loading this module.
25
-
26
- const fs = require('fs')
27
- const path = require('path')
28
-
29
- // Minimal text diff: replace only the changed middle, preserving the common
30
- // prefix/suffix. Applied to a Y.Text so edits stay small (good for CRDT churn).
31
- function applyDiff(ytext, next) {
32
- const cur = ytext.toString()
33
- if (cur === next) return false
34
- let p = 0
35
- while (p < cur.length && p < next.length && cur[p] === next[p]) p++
36
- let s = 0
37
- while (s < cur.length - p && s < next.length - p && cur[cur.length - 1 - s] === next[next.length - 1 - s]) s++
38
- if (cur.length - p - s > 0) ytext.delete(p, cur.length - p - s)
39
- const ins = next.slice(p, next.length - s)
40
- if (ins) ytext.insert(p, ins)
41
- return true
42
- }
43
-
44
- // Detect REAL git-style conflict markers in a file. Line-anchored on purpose.
45
- function hasConflictMarkers(text) {
46
- return /^<<<<<<< /m.test(text || '') && /^>>>>>>> /m.test(text || '')
47
- }
48
-
49
- // Strip and de-nest conflict markers to prevent exponential 2^N code ballooning
50
- function stripConflictMarkers(text, prefer = 'local') {
51
- if (!text || !hasConflictMarkers(text)) return text || ''
52
- const lines = text.split('\n')
53
- const out = []
54
- let inConflict = false
55
- let inTheirs = false
56
- const localBlock = []
57
- const theirsBlock = []
58
-
59
- for (let i = 0; i < lines.length; i++) {
60
- const line = lines[i]
61
- if (/^<<<<<<< /.test(line)) {
62
- inConflict = true
63
- inTheirs = false
64
- localBlock.length = 0
65
- theirsBlock.length = 0
66
- continue
67
- }
68
- if (/^=======$/.test(line) && inConflict) {
69
- inTheirs = true
70
- continue
71
- }
72
- if (/^>>>>>>> /.test(line) && inConflict) {
73
- inConflict = false
74
- out.push(...(prefer === 'theirs' ? theirsBlock : localBlock))
75
- continue
76
- }
77
- if (inConflict) {
78
- if (inTheirs) theirsBlock.push(line)
79
- else localBlock.push(line)
80
- } else {
81
- out.push(line)
82
- }
83
- }
84
- return out.join('\n')
85
- }
86
-
87
- // Which base line-range an edit replaced, and the replacement lines.
88
- function changedRange(base, other) {
89
- let p = 0
90
- while (p < base.length && p < other.length && base[p] === other[p]) p++
91
- let s = 0
92
- while (s < base.length - p && s < other.length - p && base[base.length - 1 - s] === other[other.length - 1 - s]) s++
93
- return { startBase: p, endBase: base.length - s, newLines: other.slice(p, other.length - s) }
94
- }
95
-
96
- // ── A REAL DIFF3 (27 Sep 2026) ─────────────────────────────────────────────────────────────────
97
- // changedRange() is the span from a side's FIRST changed line to its LAST. That is the right
98
- // answer to "how much changed" (summarizeChange) and the wrong one for a merge: two one-line edits
99
- // 500 lines apart are one 500-line change to it, so the other side touching anything in between
100
- // turned the whole span into a single conflict. Measured on a real cherry-pick of sync.js (a
101
- // 5-line conflict to git's diff3): one conflict block of 2,140 lines. The same copy is merge3.js's
102
- // "git-style" floor in icr-merge and extension.js's, and tests/diff3-test.mjs pins all three.
103
- //
104
- // lineHunks: Myers' O(ND) diff on what lies between the common prefix and suffix, each separate
105
- // edit as its own hunk [baseStart, baseEnd, sideStart, sideEnd). Past `maxD` edits it returns null
106
- // and merge3 keeps the one-range merge, which is coarse but never loses a line.
107
- function lineHunks(a, b, maxD = 1000) {
108
- let p = 0
109
- while (p < a.length && p < b.length && a[p] === b[p]) p++
110
- let s = 0
111
- while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++
112
- const n = a.length - p - s, m = b.length - p - s
113
- if (n === 0 && m === 0) return []
114
- if (n === 0 || m === 0) return [[p, p + n, p, p + m]]
115
- const max = Math.min(n + m, maxD)
116
- const off = max + 1
117
- const v = new Int32Array(2 * max + 3) // v[k + off]: furthest x reached on diagonal k
118
- const trace = [] // v's window [-d, d] as it stood before step d
119
- let found = -1
120
- for (let d = 0; d <= max && found < 0; d++) {
121
- trace.push(v.slice(off - d, off + d + 1))
122
- for (let k = -d; k <= d; k += 2) {
123
- let x = (k === -d || (k !== d && v[off + k - 1] < v[off + k + 1])) ? v[off + k + 1] : v[off + k - 1] + 1
124
- let y = x - k
125
- while (x < n && y < m && a[p + x] === b[p + y]) { x++; y++ }
126
- v[off + k] = x
127
- if (x >= n && y >= m) { found = d; break }
128
- }
129
- }
130
- if (found < 0) return null
131
- // Walk back from (n, m): each step d undoes one insertion or deletion, then its snake.
132
- const steps = [] // [x0, y0, x1, y1], one edit each, collected last-first
133
- let x = n, y = m
134
- for (let d = found; d > 0; d--) {
135
- const w = trace[d], at = (k) => w[k + d]
136
- const k = x - y
137
- const down = k === -d || (k !== d && at(k - 1) < at(k + 1)) // true: came by an insertion from b
138
- const pk = down ? k + 1 : k - 1
139
- const px = at(pk), py = px - pk
140
- steps.push(down ? [px, py, px, py + 1] : [px, py, px + 1, py])
141
- x = px; y = py
142
- }
143
- steps.reverse()
144
- const hunks = []
145
- for (const [x0, y0, x1, y1] of steps) {
146
- const last = hunks[hunks.length - 1]
147
- if (last && last[1] === p + x0 && last[3] === p + y0) { last[1] = p + x1; last[3] = p + y1 }
148
- else hunks.push([p + x0, p + x1, p + y0, p + y1])
149
- }
150
- return hunks
151
- }
152
-
153
- // One side's version of base lines [rs, re) with its hunks inside that range applied.
154
- function sideOfRegion(b, rs, re, hunks, src) {
155
- const out = []
156
- let cur = rs
157
- for (const [bs, be, os, oe] of hunks) {
158
- for (let i = cur; i < bs; i++) out.push(b[i])
159
- for (let i = os; i < oe; i++) out.push(src[i])
160
- cur = be
161
- }
162
- for (let i = cur; i < re; i++) out.push(b[i])
163
- return out
164
- }
165
-
166
- // Where else a hunk could sit and mean the same edit: a pure insertion or deletion inside a run of
167
- // identical lines ("}" "}" "}") can slide along it, and which position the diff chose is an
168
- // accident of the algorithm. Two sides' edits count as touching if they COULD touch, so a merge
169
- // that hinges on that accident conflicts instead of guessing (git reaches the same answer by
170
- // sliding every hunk to one canonical end first). Returns [lo, hi] in base lines.
171
- function slideSpan(b, [bs, be, os, oe], src) {
172
- let lo = bs, hi = be
173
- if (os === oe && be > bs) { // pure deletion of b[bs, be)
174
- let s = bs, e = be
175
- while (s > 0 && b[s - 1] === b[e - 1]) { s--; e-- }
176
- lo = s
177
- s = bs; e = be
178
- while (e < b.length && b[s] === b[e]) { s++; e++ }
179
- hi = e
180
- } else if (bs === be && oe > os) { // pure insertion at bs of src[os, oe)
181
- const n = oe - os
182
- let k = bs, r = 0 // r: how far the inserted block has rotated
183
- while (k > 0 && b[k - 1] === src[os + ((n - 1 - r) % n + n) % n]) { k--; r++ }
184
- lo = k
185
- k = bs; r = 0
186
- while (k < b.length && b[k] === src[os + (r % n)]) { k++; r++ }
187
- hi = k
188
- }
189
- return [lo, hi]
190
- }
191
-
192
- // diff3 over line arrays: hunks from the two sides that overlap OR TOUCH form one region (touching
193
- // changes conflict, as in git: two edits with no unchanged line between them are one decision). A
194
- // region only one side changed takes that side; both sides making the same change takes it once;
195
- // anything else is a conflict of THAT region only. Null when either diff is past the work bound.
196
- function diff3Lines(b, mine, theirs) {
197
- const hm = lineHunks(b, mine), ht = lineHunks(b, theirs)
198
- if (!hm || !ht) return null
199
- const sm = hm.map((h) => slideSpan(b, h, mine)), st = ht.map((h) => slideSpan(b, h, theirs))
200
- const out = []
201
- let conflict = false, pos = 0, i = 0, j = 0
202
- while (i < hm.length || j < ht.length) {
203
- const fromMine = j >= ht.length || (i < hm.length && hm[i][0] <= ht[j][0])
204
- const first = fromMine ? hm[i] : ht[j]
205
- let reach = fromMine ? sm[i++][1] : st[j++][1] // how far this region's edits could extend
206
- const rm = fromMine ? [first] : [], rt = fromMine ? [] : [first]
207
- const rs = first[0]
208
- let re = first[1]
209
- for (;;) {
210
- if (i < hm.length && sm[i][0] <= reach) { re = Math.max(re, hm[i][1]); reach = Math.max(reach, sm[i][1]); rm.push(hm[i++]); continue }
211
- if (j < ht.length && st[j][0] <= reach) { re = Math.max(re, ht[j][1]); reach = Math.max(reach, st[j][1]); rt.push(ht[j++]); continue }
212
- break
213
- }
214
- for (let x = pos; x < rs; x++) out.push(b[x])
215
- if (!rt.length) out.push(...sideOfRegion(b, rs, re, rm, mine))
216
- else if (!rm.length) out.push(...sideOfRegion(b, rs, re, rt, theirs))
217
- else {
218
- const vm = sideOfRegion(b, rs, re, rm, mine), vt = sideOfRegion(b, rs, re, rt, theirs)
219
- if (vm.length === vt.length && vm.every((l, k) => l === vt[k])) out.push(...vm)
220
- else { conflict = true; out.push('<<<<<<< local (yours)', ...vm, '=======', ...vt, '>>>>>>> incoming (theirs)') }
221
- }
222
- pos = re
223
- }
224
- for (let x = pos; x < b.length; x++) out.push(b[x])
225
- return { lines: out, conflict }
226
- }
227
-
228
- // Best-effort "what was defined here" across common languages — for the board.
229
- function symbolsIn(text) {
230
- const names = new Set()
231
- const add = (re) => { let m; while ((m = re.exec(text)) && names.size < 8) names.add(m[1]) }
232
- add(/\bfunction\s+(\w+)/g)
233
- add(/\bclass\s+(\w+)/g)
234
- add(/\bdef\s+(\w+)/g)
235
- add(/\b(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=/g)
236
- return [...names].slice(0, 8)
237
- }
238
-
239
- // Classify a change as a small PATCH vs wholesale REWRITE.
240
- function summarizeChange(base, next) {
241
- const b = (base || '').split('\n')
242
- const n = (next || '').split('\n')
243
- if (!base) return { isRewrite: false, changedLines: n.length, totalLines: n.length, symbols: symbolsIn(next || '') }
244
- const r = changedRange(b, n)
245
- const churn = Math.max(r.endBase - r.startBase, r.newLines.length)
246
- const total = Math.max(b.length, n.length)
247
- const isRewrite = total > 0 && churn >= 4 && churn / total >= 0.5
248
- return { isRewrite, changedLines: churn, totalLines: total, symbols: symbolsIn(r.newLines.join('\n')) }
249
- }
250
-
251
- // 3-way merge for the sync layer (disk <-> shared doc)
252
- function merge3(base, mine, theirs) {
253
- if (mine === theirs) return { text: mine, conflict: false }
254
- if (base === theirs) return { text: mine, conflict: false }
255
- if (base === mine) return { text: theirs, conflict: false }
256
-
257
- const cleanBase = hasConflictMarkers(base) ? stripConflictMarkers(base, 'local') : (base || '')
258
- const cleanMine = hasConflictMarkers(mine) ? stripConflictMarkers(mine, 'local') : (mine || '')
259
- const cleanTheirs = hasConflictMarkers(theirs) ? stripConflictMarkers(theirs, 'theirs') : (theirs || '')
260
-
261
- if (cleanMine === cleanTheirs) return { text: cleanMine, conflict: false }
262
- if (cleanBase === cleanTheirs) return { text: cleanMine, conflict: false }
263
- if (cleanBase === cleanMine) return { text: cleanTheirs, conflict: false }
264
-
265
- const b = cleanBase.split('\n')
266
- const d3 = diff3Lines(b, cleanMine.split('\n'), cleanTheirs.split('\n'))
267
- if (d3) return { text: d3.lines.join('\n'), conflict: d3.conflict }
268
- // Past diff3's work bound (two sides that share almost nothing): one range per side.
269
- const mr = changedRange(b, cleanMine.split('\n'))
270
- const tr = changedRange(b, cleanTheirs.split('\n'))
271
- if (mr.endBase <= tr.startBase || tr.endBase <= mr.startBase) {
272
- const out = b.slice()
273
- for (const e of [mr, tr].sort((x, y) => y.startBase - x.startBase)) {
274
- out.splice(e.startBase, e.endBase - e.startBase, ...e.newLines)
275
- }
276
- return { text: out.join('\n'), conflict: false }
277
- }
278
- const start = Math.min(mr.startBase, tr.startBase)
279
- const end = Math.max(mr.endBase, tr.endBase)
280
- const mineBlock = [...b.slice(start, mr.startBase), ...mr.newLines, ...b.slice(mr.endBase, end)]
281
- const theirsBlock = [...b.slice(start, tr.startBase), ...tr.newLines, ...b.slice(tr.endBase, end)]
282
- const out = [
283
- ...b.slice(0, start),
284
- '<<<<<<< local (yours)',
285
- ...mineBlock,
286
- '=======',
287
- ...theirsBlock,
288
- '>>>>>>> incoming (theirs)',
289
- ...b.slice(end),
290
- ]
291
- return { text: out.join('\n'), conflict: true }
292
- }
293
-
294
- function describeConflicts(cs) {
295
- return (cs || []).map((c) =>
296
- c.startsWith('ref:') ? `'${c.slice(4)}' was removed or renamed but is still used`
297
- : c.startsWith('fn:') ? `both sides changed function ${c.slice(3)}`
298
- : c.startsWith('class:') ? `both sides changed class ${c.slice(6)}`
299
- : `both sides changed ${c.replace(/^\w+:/, '')}`).join('; ')
300
- }
301
-
302
- function icrMerge3(base, mine, theirs, relPath, icrlib) {
303
- const lm = merge3(base, mine, theirs)
304
- if (!icrlib || !icrlib.supports || !icrlib.supports(relPath)) return lm
305
- let r
306
- try { r = icrlib.structuralMerge(base, mine, theirs, { filename: relPath }) } catch (e) { return lm }
307
- if (r.status === 'auto') {
308
- const renamed = r.renames && r.renames.length
309
- return { text: r.text, conflict: false, icr: renamed ? 'rename' : 'structural', icrWarning: renamed ? `auto-applied rename ${r.renames.join(', ')}` : undefined }
310
- }
311
- if (r.status === 'semantic-conflict') return Object.assign({}, lm, { icrWarning: describeConflicts(r.conflicts) })
312
- return lm
313
- }
314
-
315
- // createReconciler: binds to a session's maps, state, and callbacks, returning
316
- // a reconcile(relPath, origin = 'local') function.
317
- function createReconciler(ctx) {
318
- const {
319
- root,
320
- name = 'anon',
321
- SKIP = new Set(['HIVE_BOARD.md', 'HIVE_CHAT.md', 'HIVE_TASKS.md', 'HIVE_RULES.md', 'HIVE_MEMBERS.md', 'HIVE_FOR_AGENTS.md', '.hive.json']),
322
- isIgnored = () => false,
323
- canOpen = () => true,
324
- canWrite = () => true,
325
- readText = (full) => {
326
- try {
327
- const buf = fs.readFileSync(full)
328
- if (buf.length > 1_000_000 || buf.includes(0)) return null
329
- return buf.toString('utf8')
330
- } catch { return null }
331
- },
332
- writeToDisk,
333
- openFile,
334
- touchFile,
335
- fileDocs,
336
- manifest,
337
- bases,
338
- forkBases,
339
- known,
340
- mtimes,
341
- conflicted = new Set(),
342
- icrWarned = new Map(),
343
- snappedBase = new Set(),
344
- preexisting = new Set(),
345
- priorShadow,
346
- shadow,
347
- say = () => {},
348
- log = () => {},
349
- markEditing = () => {},
350
- noteCoEditing = () => {},
351
- noteIfRewrite = () => {},
352
- captureSnapshot = () => {},
353
- lastLocalEdit,
354
- // Fork-gate & provenance
355
- FORK_GATE = false,
356
- captureLocal,
357
- forkRecord,
358
- forkBlock,
359
- detectFork,
360
- renderConflict,
361
- adoptResolved,
362
- stampProvenance,
363
- attestHead,
364
- fusionReason,
365
- supports,
366
- languageFor,
367
- icrlib
368
- } = ctx
369
-
370
- return function reconcile(relPath, origin = 'local') {
371
- if (SKIP.has(relPath) || isIgnored(relPath)) return
372
- if (!canOpen(relPath)) return
373
- const full = path.join(root, relPath)
374
- const exists = fs.existsSync(full)
375
- const disk = exists ? readText(full) : null
376
- if (exists && disk === null) return
377
-
378
- if (origin === 'local' && !canWrite(relPath)) {
379
- const fe2 = fileDocs.get(relPath)
380
- if (fe2 && fe2.synced) {
381
- const dt = fe2.text.toString()
382
- if (dt && dt !== disk) writeToDisk(relPath, dt)
383
- }
384
- return
385
- }
386
-
387
- let fe = fileDocs.get(relPath)
388
- if (!fe) { if (disk === null) return; openFile(relPath); return }
389
- if (!fe.synced) return
390
- if (touchFile) touchFile(relPath)
391
-
392
- if (FORK_GATE && captureLocal) captureLocal(relPath)
393
-
394
- if (FORK_GATE && fe.conflict && forkRecord) {
395
- const rec = forkRecord(fe)
396
- if (rec) {
397
- const block = forkBlock(fe, rec)
398
- if (origin === 'local') {
399
- if (disk === block) return
400
- const fork0 = forkBases.get(relPath)
401
- const sawMarkers = fork0 === block || hasConflictMarkers(fork0 || '')
402
- if (sawMarkers && disk !== null && !hasConflictMarkers(disk)) {
403
- fe.doc.transact(() => {
404
- applyDiff(fe.text, disk)
405
- for (const v of rec.versions) fe.forkmark.set(v.hash, 1)
406
- for (const key of [...fe.conflict.keys()]) fe.conflict.delete(key)
407
- })
408
- if (stampProvenance) stampProvenance(relPath, block || '', disk)
409
- conflicted.delete(relPath)
410
- known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
411
- try { say(`✓ conflict in ${relPath} resolved by ${name}.`) } catch { }
412
- return
413
- }
414
- if (!sawMarkers && disk !== null && disk !== fork0) {
415
- if (detectFork) detectFork(relPath)
416
- return
417
- }
418
- if (renderConflict) renderConflict(relPath)
419
- return
420
- }
421
- if (renderConflict) renderConflict(relPath)
422
- return
423
- }
424
- if (disk !== null && hasConflictMarkers(disk) && !hasConflictMarkers(fe.text.toString())) {
425
- if (adoptResolved) { adoptResolved(relPath); return }
426
- }
427
- }
428
-
429
- const docText = fe.text.toString()
430
- const noHistory = !bases.has(relPath) && !(priorShadow && priorShadow.has(relPath))
431
- if (docText === '' && (!manifest.has(relPath) || (disk && noHistory))) {
432
- if (disk === null) return
433
- fe.doc.transact(() => applyDiff(fe.text, disk))
434
- if (stampProvenance) stampProvenance(relPath, '', disk)
435
- manifest.set(relPath, 1)
436
- known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
437
- if (shadow) shadow.set(relPath, disk)
438
- try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
439
- captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'created' })
440
- snappedBase.add(relPath)
441
- return
442
- }
443
-
444
- const yt = fe.text
445
- if (disk === null) {
446
- writeToDisk(relPath, docText)
447
- bases.set(relPath, docText); forkBases.set(relPath, docText)
448
- return
449
- }
450
-
451
- if (disk === docText) {
452
- if (origin === 'local' && !known.has(relPath) && disk) markEditing(relPath)
453
- known.add(relPath); bases.set(relPath, disk)
454
- if (shadow) shadow.set(relPath, disk)
455
- if (!forkBases.has(relPath) || origin === 'local') forkBases.set(relPath, disk)
456
- if (!snappedBase.has(relPath) && disk) {
457
- captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'baseline' })
458
- snappedBase.add(relPath)
459
- }
460
- return
461
- }
462
-
463
- // FIRST-CONTACT ADOPT
464
- if (!bases.has(relPath) && docText !== '' && disk !== docText) {
465
- if (!preexisting.has(relPath)) {
466
- const nl = (s) => (s === '' || s.endsWith('\n') ? s : s + '\n')
467
- const r0 = icrMerge3('', nl(disk), nl(docText), relPath, icrlib)
468
- if (r0.text !== docText) fe.doc.transact(() => applyDiff(yt, r0.text))
469
- if (r0.text !== disk) writeToDisk(relPath, r0.text)
470
- if (r0.conflict) conflicted.add(relPath)
471
- bases.set(relPath, r0.text); forkBases.set(relPath, r0.text)
472
- if (shadow) shadow.set(relPath, r0.text)
473
- if (!manifest.has(relPath)) manifest.set(relPath, 1)
474
- try { say(`⚠ ${relPath} was created by two participants at once — both versions were merged${r0.conflict ? ', and CONFLICT markers need resolving' : ''}.`) } catch { }
475
- return
476
- }
477
- const anc = priorShadow ? priorShadow.get(relPath) : undefined
478
- if (anc !== undefined && disk !== null) {
479
- if (disk === anc) {
480
- writeToDisk(relPath, docText)
481
- bases.set(relPath, docText); forkBases.set(relPath, docText)
482
- if (!snappedBase.has(relPath)) {
483
- captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'room copy adopted on rejoin (local was unchanged)' })
484
- snappedBase.add(relPath)
485
- }
486
- return
487
- }
488
- captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy before rejoin merge' })
489
- const rj = icrMerge3(anc, disk, docText, relPath, icrlib)
490
- if (rj.text !== docText) fe.doc.transact(() => applyDiff(yt, rj.text))
491
- if (rj.text !== disk) writeToDisk(relPath, rj.text)
492
- if (rj.conflict) conflicted.add(relPath)
493
- bases.set(relPath, rj.text); forkBases.set(relPath, rj.text)
494
- if (shadow) shadow.set(relPath, rj.text)
495
- if (!manifest.has(relPath)) manifest.set(relPath, 1)
496
- try { say(`⚠ ${relPath} changed locally while ${name} was disconnected — the local edit and the room copy were merged${rj.conflict ? ', and CONFLICT markers need resolving' : ''}. Pre-merge local copy is in the file's history.`) } catch { }
497
- return
498
- }
499
- captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy at join (room copy adopted)' })
500
- writeToDisk(relPath, docText)
501
- bases.set(relPath, docText); forkBases.set(relPath, docText)
502
- if (!snappedBase.has(relPath)) {
503
- captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'adopted from room at join' })
504
- snappedBase.add(relPath)
505
- }
506
- try { say(`↓ ${relPath} already existed in this room — ${name} adopted the room's copy. The local version before joining is saved in the file's history.`) } catch { }
507
- return
508
- }
509
-
510
- const base = bases.has(relPath) ? bases.get(relPath) : disk
511
- const fork = forkBases.has(relPath) ? forkBases.get(relPath) : base
512
- if (origin === 'local') { noteCoEditing(relPath); markEditing(relPath) }
513
-
514
- let res, reAdded = false
515
- if (origin === 'local' && hasConflictMarkers(docText) && !hasConflictMarkers(disk)) {
516
- res = { text: disk, conflict: false }
517
- } else if (origin === 'local') {
518
- noteIfRewrite(relPath, fork, disk)
519
- const sc = summarizeChange(fork, disk)
520
- const isCleanRewrite = sc.isRewrite && supports && supports(relPath) && languageFor && languageFor(relPath).parses(disk)
521
- const theirsNew = changedRange(fork.split('\n'), docText.split('\n')).newLines.filter((l) => l.length)
522
- const mineLines = new Set(disk.split('\n'))
523
- const integrated = theirsNew.every((l) => mineLines.has(l))
524
- if (integrated || (isCleanRewrite && !hasConflictMarkers(docText))) {
525
- res = { text: disk, conflict: false }
526
- } else {
527
- res = icrMerge3(fork, disk, docText, relPath, icrlib)
528
- reAdded = !res.conflict && res.text !== disk
529
- }
530
- } else {
531
- res = icrMerge3(base, disk, docText, relPath, icrlib)
532
- }
533
-
534
- if (res && !res.conflict && fusionReason) {
535
- const why = fusionReason(res.text, disk, docText, (supports && supports(relPath)) ? languageFor(relPath) : null)
536
- if (why) {
537
- conflicted.add(relPath)
538
- if (icrWarned.get(relPath) !== why) {
539
- icrWarned.set(relPath, why)
540
- log(`[${name}] !! REFUSED a fused merge of ${relPath}: ${why}`)
541
- try {
542
- say(`[!] @all ${relPath}: a merge was REFUSED because it fused rather than merged — ${why}. Nothing was written: the room's copy and ${name}'s local copy are both intact and unchanged. Someone must reconcile them deliberately; a plain file write will merge against the other version again, hive_edit replaces it outright.`)
543
- } catch { }
544
- }
545
- return
546
- }
547
- }
548
-
549
- if (origin === 'local' && res.text !== docText) {
550
- const sc = summarizeChange(docText, res.text)
551
- captureSnapshot(relPath, docText, name, { force: sc.isRewrite, churn: `${sc.changedLines}/${sc.totalLines} lines` })
552
- if (typeof lastLocalEdit === 'function') lastLocalEdit(relPath)
553
- }
554
- fe.doc.transact(() => applyDiff(yt, res.text))
555
- if (origin === 'local') {
556
- if (FORK_GATE) { if (res.text !== docText && attestHead) attestHead(relPath, res.text, docText) }
557
- else if (res.text !== docText && stampProvenance) stampProvenance(relPath, docText, res.text)
558
- }
559
- if (res.text !== disk) writeToDisk(relPath, res.text)
560
- known.add(relPath); bases.set(relPath, res.text)
561
- if (origin === 'local') forkBases.set(relPath, res.text)
562
- else if (!forkBases.get(relPath)) forkBases.set(relPath, res.text)
563
-
564
- const hasMarkers = res.conflict || hasConflictMarkers(res.text)
565
- if (hasMarkers && !conflicted.has(relPath)) {
566
- conflicted.add(relPath)
567
- log(`[${name}] ⚠ merge conflict in ${relPath} — kept BOTH versions with <<<<<<< markers`)
568
- try { say(`⚠ MERGE CONFLICT in ${relPath} — it has <<<<<<< markers. Whoever owns it: resolve before continuing.`) } catch { }
569
- } else if (!hasMarkers && conflicted.has(relPath)) {
570
- conflicted.delete(relPath)
571
- try { say(`✓ conflict in ${relPath} resolved.`) } catch { }
572
- } else if (reAdded) {
573
- log(`[${name}] protected ${relPath}: your edit was based on an older version — re-added changes that arrived since (nobody's work lost)`)
574
- try { say(`↺ ${relPath}: ${name}'s edit was based on an older copy — kept the changes that landed in between (nothing lost).`) } catch { }
575
- } else if (!hasMarkers && res.icr === 'rename') {
576
- log(`[${name}] ICR merged ${relPath} (detected a rename and updated the call sites)`)
577
- } else if (!hasMarkers && res.icr === 'structural') {
578
- log(`[${name}] ICR merged ${relPath} structurally (both edits kept, syntax guaranteed)`)
579
- } else if (!hasMarkers) {
580
- log(`[${name}] merged ${relPath} (both edits kept)`)
581
- }
582
-
583
- if (res.icrWarning && icrWarned.get(relPath) !== res.icrWarning) {
584
- icrWarned.set(relPath, res.icrWarning)
585
- log(`[${name}] ⚠ ICR flag in ${relPath} — ${res.icrWarning}`)
586
- try { say(`⚠ ICR flag in ${relPath}: ${res.icrWarning}. It merged, but review — this is a break a plain merge can't see.`) } catch { }
587
- } else if (!res.icrWarning && icrWarned.has(relPath)) {
588
- icrWarned.delete(relPath)
589
- }
590
-
591
- if (detectFork) detectFork(relPath)
592
- }
593
- }
594
-
595
- // reconcile direct call
596
- function reconcile(ctx, relPath, origin = 'local') {
597
- return createReconciler(ctx)(relPath, origin)
598
- }
599
-
600
- module.exports = {
601
- reconcile,
602
- createReconciler,
603
- applyDiff,
604
- hasConflictMarkers,
605
- stripConflictMarkers,
606
- changedRange,
607
- symbolsIn,
608
- summarizeChange,
609
- merge3,
610
- lineHunks,
611
- diff3Lines,
612
- describeConflicts,
613
- icrMerge3
614
- }
1
+ // reconcile.cjs — Single source of truth for the 3-way reconcile & merge engine.
2
+ //
3
+ // CommonJS on purpose: sync.js is ESM and the VS Code extension is CJS.
4
+ // A .cjs file is the one shape both can load synchronously without duplication.
5
+ //
6
+ // Acceptance criteria (HIV-8), re-checked 21 Sep 2026 -- FOUR OF FIVE MET:
7
+ // 1. MET one module exports reconcile() and its dependencies (11 exports).
8
+ // 2. MET core.js re-exports from here and sync.js imports core.js. One copy.
9
+ // 3. NOT MET extension/extension.js does NOT require this file. It requires
10
+ // ./icr.cjs (the ICR bundle) and keeps its OWN reconcile() at
11
+ // extension/extension.js:1166. The second copy is still there.
12
+ // 4. MET node tests/test.js -- 95 passed, 0 failed.
13
+ // 5. MET node tests/conflict-test.mjs -- ALL 4 PASS.
14
+ //
15
+ // Criterion 3 read "No second copy anywhere" while a second copy existed, in a file
16
+ // that ships in package.json files[]. A comment asserting an invariant it does not
17
+ // hold is worse than no comment: the next reader trusts it and stops looking. Corrected
18
+ // to what is true rather than deleted, because the gap is the useful part.
19
+ //
20
+ // WHAT ACTUALLY GUARDS THE DRIFT, since it is not this file:
21
+ // tests/extension-parity-test.mjs the ICR bundle vs the engine, 24/24
22
+ // tests/extension-reconcile-characterisation-test.mjs pins the extension's own copy
23
+ // Those two measure that the copies AGREE. They do not make it one copy, and closing
24
+ // criterion 3 for real means the extension loading this module.
25
+
26
+ const fs = require('fs')
27
+ const path = require('path')
28
+
29
+ // Minimal text diff: replace only the changed middle, preserving the common
30
+ // prefix/suffix. Applied to a Y.Text so edits stay small (good for CRDT churn).
31
+ function applyDiff(ytext, next) {
32
+ const cur = ytext.toString()
33
+ if (cur === next) return false
34
+ let p = 0
35
+ while (p < cur.length && p < next.length && cur[p] === next[p]) p++
36
+ let s = 0
37
+ while (s < cur.length - p && s < next.length - p && cur[cur.length - 1 - s] === next[next.length - 1 - s]) s++
38
+ if (cur.length - p - s > 0) ytext.delete(p, cur.length - p - s)
39
+ const ins = next.slice(p, next.length - s)
40
+ if (ins) ytext.insert(p, ins)
41
+ return true
42
+ }
43
+
44
+ // Detect REAL git-style conflict markers in a file. Line-anchored on purpose.
45
+ function hasConflictMarkers(text) {
46
+ return /^<<<<<<< /m.test(text || '') && /^>>>>>>> /m.test(text || '')
47
+ }
48
+
49
+ // Strip and de-nest conflict markers to prevent exponential 2^N code ballooning
50
+ function stripConflictMarkers(text, prefer = 'local') {
51
+ if (!text || !hasConflictMarkers(text)) return text || ''
52
+ const lines = text.split('\n')
53
+ const out = []
54
+ let inConflict = false
55
+ let inTheirs = false
56
+ const localBlock = []
57
+ const theirsBlock = []
58
+
59
+ for (let i = 0; i < lines.length; i++) {
60
+ const line = lines[i]
61
+ if (/^<<<<<<< /.test(line)) {
62
+ inConflict = true
63
+ inTheirs = false
64
+ localBlock.length = 0
65
+ theirsBlock.length = 0
66
+ continue
67
+ }
68
+ if (/^=======$/.test(line) && inConflict) {
69
+ inTheirs = true
70
+ continue
71
+ }
72
+ if (/^>>>>>>> /.test(line) && inConflict) {
73
+ inConflict = false
74
+ out.push(...(prefer === 'theirs' ? theirsBlock : localBlock))
75
+ continue
76
+ }
77
+ if (inConflict) {
78
+ if (inTheirs) theirsBlock.push(line)
79
+ else localBlock.push(line)
80
+ } else {
81
+ out.push(line)
82
+ }
83
+ }
84
+ return out.join('\n')
85
+ }
86
+
87
+ // Which base line-range an edit replaced, and the replacement lines.
88
+ function changedRange(base, other) {
89
+ let p = 0
90
+ while (p < base.length && p < other.length && base[p] === other[p]) p++
91
+ let s = 0
92
+ while (s < base.length - p && s < other.length - p && base[base.length - 1 - s] === other[other.length - 1 - s]) s++
93
+ return { startBase: p, endBase: base.length - s, newLines: other.slice(p, other.length - s) }
94
+ }
95
+
96
+ // ── A REAL DIFF3 (27 Sep 2026) ─────────────────────────────────────────────────────────────────
97
+ // changedRange() is the span from a side's FIRST changed line to its LAST. That is the right
98
+ // answer to "how much changed" (summarizeChange) and the wrong one for a merge: two one-line edits
99
+ // 500 lines apart are one 500-line change to it, so the other side touching anything in between
100
+ // turned the whole span into a single conflict. Measured on a real cherry-pick of sync.js (a
101
+ // 5-line conflict to git's diff3): one conflict block of 2,140 lines. The same copy is merge3.js's
102
+ // "git-style" floor in icr-merge and extension.js's, and tests/diff3-test.mjs pins all three.
103
+ //
104
+ // lineHunks: Myers' O(ND) diff on what lies between the common prefix and suffix, each separate
105
+ // edit as its own hunk [baseStart, baseEnd, sideStart, sideEnd). Past `maxD` edits it returns null
106
+ // and merge3 keeps the one-range merge, which is coarse but never loses a line.
107
+ function lineHunks(a, b, maxD = 1000) {
108
+ let p = 0
109
+ while (p < a.length && p < b.length && a[p] === b[p]) p++
110
+ let s = 0
111
+ while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++
112
+ const n = a.length - p - s, m = b.length - p - s
113
+ if (n === 0 && m === 0) return []
114
+ if (n === 0 || m === 0) return [[p, p + n, p, p + m]]
115
+ const max = Math.min(n + m, maxD)
116
+ const off = max + 1
117
+ const v = new Int32Array(2 * max + 3) // v[k + off]: furthest x reached on diagonal k
118
+ const trace = [] // v's window [-d, d] as it stood before step d
119
+ let found = -1
120
+ for (let d = 0; d <= max && found < 0; d++) {
121
+ trace.push(v.slice(off - d, off + d + 1))
122
+ for (let k = -d; k <= d; k += 2) {
123
+ let x = (k === -d || (k !== d && v[off + k - 1] < v[off + k + 1])) ? v[off + k + 1] : v[off + k - 1] + 1
124
+ let y = x - k
125
+ while (x < n && y < m && a[p + x] === b[p + y]) { x++; y++ }
126
+ v[off + k] = x
127
+ if (x >= n && y >= m) { found = d; break }
128
+ }
129
+ }
130
+ if (found < 0) return null
131
+ // Walk back from (n, m): each step d undoes one insertion or deletion, then its snake.
132
+ const steps = [] // [x0, y0, x1, y1], one edit each, collected last-first
133
+ let x = n, y = m
134
+ for (let d = found; d > 0; d--) {
135
+ const w = trace[d], at = (k) => w[k + d]
136
+ const k = x - y
137
+ const down = k === -d || (k !== d && at(k - 1) < at(k + 1)) // true: came by an insertion from b
138
+ const pk = down ? k + 1 : k - 1
139
+ const px = at(pk), py = px - pk
140
+ steps.push(down ? [px, py, px, py + 1] : [px, py, px + 1, py])
141
+ x = px; y = py
142
+ }
143
+ steps.reverse()
144
+ const hunks = []
145
+ for (const [x0, y0, x1, y1] of steps) {
146
+ const last = hunks[hunks.length - 1]
147
+ if (last && last[1] === p + x0 && last[3] === p + y0) { last[1] = p + x1; last[3] = p + y1 }
148
+ else hunks.push([p + x0, p + x1, p + y0, p + y1])
149
+ }
150
+ return hunks
151
+ }
152
+
153
+ // One side's version of base lines [rs, re) with its hunks inside that range applied.
154
+ function sideOfRegion(b, rs, re, hunks, src) {
155
+ const out = []
156
+ let cur = rs
157
+ for (const [bs, be, os, oe] of hunks) {
158
+ for (let i = cur; i < bs; i++) out.push(b[i])
159
+ for (let i = os; i < oe; i++) out.push(src[i])
160
+ cur = be
161
+ }
162
+ for (let i = cur; i < re; i++) out.push(b[i])
163
+ return out
164
+ }
165
+
166
+ // Where else a hunk could sit and mean the same edit: a pure insertion or deletion inside a run of
167
+ // identical lines ("}" "}" "}") can slide along it, and which position the diff chose is an
168
+ // accident of the algorithm. Two sides' edits count as touching if they COULD touch, so a merge
169
+ // that hinges on that accident conflicts instead of guessing (git reaches the same answer by
170
+ // sliding every hunk to one canonical end first). Returns [lo, hi] in base lines.
171
+ function slideSpan(b, [bs, be, os, oe], src) {
172
+ let lo = bs, hi = be
173
+ if (os === oe && be > bs) { // pure deletion of b[bs, be)
174
+ let s = bs, e = be
175
+ while (s > 0 && b[s - 1] === b[e - 1]) { s--; e-- }
176
+ lo = s
177
+ s = bs; e = be
178
+ while (e < b.length && b[s] === b[e]) { s++; e++ }
179
+ hi = e
180
+ } else if (bs === be && oe > os) { // pure insertion at bs of src[os, oe)
181
+ const n = oe - os
182
+ let k = bs, r = 0 // r: how far the inserted block has rotated
183
+ while (k > 0 && b[k - 1] === src[os + ((n - 1 - r) % n + n) % n]) { k--; r++ }
184
+ lo = k
185
+ k = bs; r = 0
186
+ while (k < b.length && b[k] === src[os + (r % n)]) { k++; r++ }
187
+ hi = k
188
+ }
189
+ return [lo, hi]
190
+ }
191
+
192
+ // diff3 over line arrays: hunks from the two sides that overlap OR TOUCH form one region (touching
193
+ // changes conflict, as in git: two edits with no unchanged line between them are one decision). A
194
+ // region only one side changed takes that side; both sides making the same change takes it once;
195
+ // anything else is a conflict of THAT region only. Null when either diff is past the work bound.
196
+ function diff3Lines(b, mine, theirs) {
197
+ const hm = lineHunks(b, mine), ht = lineHunks(b, theirs)
198
+ if (!hm || !ht) return null
199
+ const sm = hm.map((h) => slideSpan(b, h, mine)), st = ht.map((h) => slideSpan(b, h, theirs))
200
+ const out = []
201
+ let conflict = false, pos = 0, i = 0, j = 0
202
+ while (i < hm.length || j < ht.length) {
203
+ const fromMine = j >= ht.length || (i < hm.length && hm[i][0] <= ht[j][0])
204
+ const first = fromMine ? hm[i] : ht[j]
205
+ let reach = fromMine ? sm[i++][1] : st[j++][1] // how far this region's edits could extend
206
+ const rm = fromMine ? [first] : [], rt = fromMine ? [] : [first]
207
+ const rs = first[0]
208
+ let re = first[1]
209
+ for (;;) {
210
+ if (i < hm.length && sm[i][0] <= reach) { re = Math.max(re, hm[i][1]); reach = Math.max(reach, sm[i][1]); rm.push(hm[i++]); continue }
211
+ if (j < ht.length && st[j][0] <= reach) { re = Math.max(re, ht[j][1]); reach = Math.max(reach, st[j][1]); rt.push(ht[j++]); continue }
212
+ break
213
+ }
214
+ for (let x = pos; x < rs; x++) out.push(b[x])
215
+ if (!rt.length) out.push(...sideOfRegion(b, rs, re, rm, mine))
216
+ else if (!rm.length) out.push(...sideOfRegion(b, rs, re, rt, theirs))
217
+ else {
218
+ const vm = sideOfRegion(b, rs, re, rm, mine), vt = sideOfRegion(b, rs, re, rt, theirs)
219
+ if (vm.length === vt.length && vm.every((l, k) => l === vt[k])) out.push(...vm)
220
+ else { conflict = true; out.push('<<<<<<< local (yours)', ...vm, '=======', ...vt, '>>>>>>> incoming (theirs)') }
221
+ }
222
+ pos = re
223
+ }
224
+ for (let x = pos; x < b.length; x++) out.push(b[x])
225
+ return { lines: out, conflict }
226
+ }
227
+
228
+ // Best-effort "what was defined here" across common languages — for the board.
229
+ function symbolsIn(text) {
230
+ const names = new Set()
231
+ const add = (re) => { let m; while ((m = re.exec(text)) && names.size < 8) names.add(m[1]) }
232
+ add(/\bfunction\s+(\w+)/g)
233
+ add(/\bclass\s+(\w+)/g)
234
+ add(/\bdef\s+(\w+)/g)
235
+ add(/\b(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=/g)
236
+ return [...names].slice(0, 8)
237
+ }
238
+
239
+ // Classify a change as a small PATCH vs wholesale REWRITE.
240
+ function summarizeChange(base, next) {
241
+ const b = (base || '').split('\n')
242
+ const n = (next || '').split('\n')
243
+ if (!base) return { isRewrite: false, changedLines: n.length, totalLines: n.length, symbols: symbolsIn(next || '') }
244
+ const r = changedRange(b, n)
245
+ const churn = Math.max(r.endBase - r.startBase, r.newLines.length)
246
+ const total = Math.max(b.length, n.length)
247
+ const isRewrite = total > 0 && churn >= 4 && churn / total >= 0.5
248
+ return { isRewrite, changedLines: churn, totalLines: total, symbols: symbolsIn(r.newLines.join('\n')) }
249
+ }
250
+
251
+ // 3-way merge for the sync layer (disk <-> shared doc)
252
+ function merge3(base, mine, theirs) {
253
+ const norm = (s) => (s || '').replace(/\r\n/g, '\n')
254
+ const nm0 = norm(mine), nt0 = norm(theirs), nb0 = norm(base)
255
+ if (nm0 === nt0) return { text: nt0, conflict: false }
256
+ if (nb0 === nt0) return { text: nm0, conflict: false }
257
+ if (nb0 === nm0) return { text: nt0, conflict: false }
258
+
259
+ const cleanBase = hasConflictMarkers(base) ? stripConflictMarkers(base, 'local') : (base || '')
260
+ const cleanMine = hasConflictMarkers(mine) ? stripConflictMarkers(mine, 'local') : (mine || '')
261
+ const cleanTheirs = hasConflictMarkers(theirs) ? stripConflictMarkers(theirs, 'theirs') : (theirs || '')
262
+
263
+ const nm = norm(cleanMine), nt = norm(cleanTheirs), nb = norm(cleanBase)
264
+ if (nm === nt) return { text: nt, conflict: false }
265
+ if (nb === nt) return { text: nm, conflict: false }
266
+ if (nb === nm) return { text: nt, conflict: false }
267
+
268
+ const b = nb.split('\n')
269
+ const d3 = diff3Lines(b, nm.split('\n'), nt.split('\n'))
270
+ if (d3) return { text: d3.lines.join('\n'), conflict: d3.conflict }
271
+ // Past diff3's work bound (two sides that share almost nothing): one range per side.
272
+ const mr = changedRange(b, nm.split('\n'))
273
+ const tr = changedRange(b, nt.split('\n'))
274
+ if (mr.endBase <= tr.startBase || tr.endBase <= mr.startBase) {
275
+ const out = b.slice()
276
+ for (const e of [mr, tr].sort((x, y) => y.startBase - x.startBase)) {
277
+ out.splice(e.startBase, e.endBase - e.startBase, ...e.newLines)
278
+ }
279
+ return { text: out.join('\n'), conflict: false }
280
+ }
281
+ const start = Math.min(mr.startBase, tr.startBase)
282
+ const end = Math.max(mr.endBase, tr.endBase)
283
+ const mineBlock = [...b.slice(start, mr.startBase), ...mr.newLines, ...b.slice(mr.endBase, end)]
284
+ const theirsBlock = [...b.slice(start, tr.startBase), ...tr.newLines, ...b.slice(tr.endBase, end)]
285
+ if (mineBlock.length === theirsBlock.length && mineBlock.every((l, k) => l === theirsBlock[k])) {
286
+ const out = [...b.slice(0, start), ...theirsBlock, ...b.slice(end)]
287
+ return { text: out.join('\n'), conflict: false }
288
+ }
289
+ const out = [
290
+ ...b.slice(0, start),
291
+ '<<<<<<< local (yours)',
292
+ ...mineBlock,
293
+ '=======',
294
+ ...theirsBlock,
295
+ '>>>>>>> incoming (theirs)',
296
+ ...b.slice(end),
297
+ ]
298
+ return { text: out.join('\n'), conflict: true }
299
+ }
300
+
301
+ function describeConflicts(cs) {
302
+ return (cs || []).map((c) =>
303
+ c.startsWith('ref:') ? `'${c.slice(4)}' was removed or renamed but is still used`
304
+ : c.startsWith('fn:') ? `both sides changed function ${c.slice(3)}`
305
+ : c.startsWith('class:') ? `both sides changed class ${c.slice(6)}`
306
+ : `both sides changed ${c.replace(/^\w+:/, '')}`).join('; ')
307
+ }
308
+
309
+ function icrMerge3(base, mine, theirs, relPath, icrlib) {
310
+ const lm = merge3(base, mine, theirs)
311
+ if (!icrlib || !icrlib.supports || !icrlib.supports(relPath)) return lm
312
+ let r
313
+ try { r = icrlib.structuralMerge(base, mine, theirs, { filename: relPath }) } catch (e) { return lm }
314
+ if (r.status === 'auto') {
315
+ const renamed = r.renames && r.renames.length
316
+ return { text: r.text, conflict: false, icr: renamed ? 'rename' : 'structural', icrWarning: renamed ? `auto-applied rename ${r.renames.join(', ')}` : undefined }
317
+ }
318
+ if (r.status === 'semantic-conflict') return Object.assign({}, lm, { icrWarning: describeConflicts(r.conflicts) })
319
+ return lm
320
+ }
321
+
322
+ // createReconciler: binds to a session's maps, state, and callbacks, returning
323
+ // a reconcile(relPath, origin = 'local') function.
324
+ function createReconciler(ctx) {
325
+ const {
326
+ root,
327
+ name = 'anon',
328
+ SKIP = new Set(['HIVE_BOARD.md', 'HIVE_CHAT.md', 'HIVE_TASKS.md', 'HIVE_RULES.md', 'HIVE_MEMBERS.md', 'HIVE_FOR_AGENTS.md', '.hive.json']),
329
+ isIgnored = () => false,
330
+ canOpen = () => true,
331
+ canWrite = () => true,
332
+ readText = (full) => {
333
+ try {
334
+ const buf = fs.readFileSync(full)
335
+ if (buf.length > 1_000_000 || buf.includes(0)) return null
336
+ return buf.toString('utf8')
337
+ } catch { return null }
338
+ },
339
+ writeToDisk,
340
+ openFile,
341
+ touchFile,
342
+ fileDocs,
343
+ manifest,
344
+ bases,
345
+ forkBases,
346
+ known,
347
+ mtimes,
348
+ conflicted = new Set(),
349
+ icrWarned = new Map(),
350
+ snappedBase = new Set(),
351
+ preexisting = new Set(),
352
+ priorShadow,
353
+ shadow,
354
+ say = () => {},
355
+ log = () => {},
356
+ markEditing = () => {},
357
+ noteCoEditing = () => {},
358
+ noteIfRewrite = () => {},
359
+ captureSnapshot = () => {},
360
+ lastLocalEdit,
361
+ // Fork-gate & provenance
362
+ FORK_GATE = false,
363
+ captureLocal,
364
+ forkRecord,
365
+ forkBlock,
366
+ detectFork,
367
+ renderConflict,
368
+ adoptResolved,
369
+ stampProvenance,
370
+ attestHead,
371
+ fusionReason,
372
+ supports,
373
+ languageFor,
374
+ icrlib
375
+ } = ctx
376
+
377
+ return function reconcile(relPath, origin = 'local') {
378
+ if (SKIP.has(relPath) || isIgnored(relPath)) return
379
+ if (!canOpen(relPath)) return
380
+ const full = path.join(root, relPath)
381
+ const exists = fs.existsSync(full)
382
+ const disk = exists ? readText(full) : null
383
+ if (exists && disk === null) return
384
+
385
+ if (origin === 'local' && !canWrite(relPath)) {
386
+ const fe2 = fileDocs.get(relPath)
387
+ if (fe2 && fe2.synced) {
388
+ const dt = fe2.text.toString()
389
+ if (dt && dt !== disk) writeToDisk(relPath, dt)
390
+ }
391
+ return
392
+ }
393
+
394
+ let fe = fileDocs.get(relPath)
395
+ if (!fe) { if (disk === null) return; openFile(relPath); return }
396
+ if (!fe.synced) return
397
+ if (touchFile) touchFile(relPath)
398
+
399
+ if (FORK_GATE && captureLocal) captureLocal(relPath)
400
+
401
+ if (FORK_GATE && fe.conflict && forkRecord) {
402
+ const rec = forkRecord(fe)
403
+ if (rec) {
404
+ const block = forkBlock(fe, rec)
405
+ if (origin === 'local') {
406
+ if (disk === block) return
407
+ const fork0 = forkBases.get(relPath)
408
+ const sawMarkers = fork0 === block || hasConflictMarkers(fork0 || '')
409
+ if (sawMarkers && disk !== null && !hasConflictMarkers(disk)) {
410
+ fe.doc.transact(() => {
411
+ applyDiff(fe.text, disk)
412
+ for (const v of rec.versions) fe.forkmark.set(v.hash, 1)
413
+ for (const key of [...fe.conflict.keys()]) fe.conflict.delete(key)
414
+ })
415
+ if (stampProvenance) stampProvenance(relPath, block || '', disk)
416
+ conflicted.delete(relPath)
417
+ known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
418
+ try { say(`✓ conflict in ${relPath} resolved by ${name}.`) } catch { }
419
+ return
420
+ }
421
+ if (!sawMarkers && disk !== null && disk !== fork0) {
422
+ if (detectFork) detectFork(relPath)
423
+ return
424
+ }
425
+ if (renderConflict) renderConflict(relPath)
426
+ return
427
+ }
428
+ if (renderConflict) renderConflict(relPath)
429
+ return
430
+ }
431
+ if (disk !== null && hasConflictMarkers(disk) && !hasConflictMarkers(fe.text.toString())) {
432
+ if (adoptResolved) { adoptResolved(relPath); return }
433
+ }
434
+ }
435
+
436
+ const docText = fe.text.toString()
437
+ const noHistory = !bases.has(relPath) && !(priorShadow && priorShadow.has(relPath))
438
+ if (docText === '' && (!manifest.has(relPath) || (disk && noHistory))) {
439
+ if (disk === null) return
440
+ fe.doc.transact(() => applyDiff(fe.text, disk))
441
+ if (stampProvenance) stampProvenance(relPath, '', disk)
442
+ manifest.set(relPath, 1)
443
+ known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
444
+ if (shadow) shadow.set(relPath, disk)
445
+ try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
446
+ captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'created' })
447
+ snappedBase.add(relPath)
448
+ return
449
+ }
450
+
451
+ const yt = fe.text
452
+ if (disk === null) {
453
+ writeToDisk(relPath, docText)
454
+ bases.set(relPath, docText); forkBases.set(relPath, docText)
455
+ return
456
+ }
457
+
458
+ if (disk === docText) {
459
+ if (origin === 'local' && !known.has(relPath) && disk) markEditing(relPath)
460
+ known.add(relPath); bases.set(relPath, disk)
461
+ if (shadow) shadow.set(relPath, disk)
462
+ if (!forkBases.has(relPath) || origin === 'local') forkBases.set(relPath, disk)
463
+ if (!snappedBase.has(relPath) && disk) {
464
+ captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'baseline' })
465
+ snappedBase.add(relPath)
466
+ }
467
+ return
468
+ }
469
+
470
+ // FIRST-CONTACT ADOPT
471
+ if (!bases.has(relPath) && docText !== '' && disk !== docText) {
472
+ if (!preexisting.has(relPath)) {
473
+ const nl = (s) => (s === '' || s.endsWith('\n') ? s : s + '\n')
474
+ const r0 = icrMerge3('', nl(disk), nl(docText), relPath, icrlib)
475
+ if (r0.text !== docText) fe.doc.transact(() => applyDiff(yt, r0.text))
476
+ if (r0.text !== disk) writeToDisk(relPath, r0.text)
477
+ if (r0.conflict) conflicted.add(relPath)
478
+ bases.set(relPath, r0.text); forkBases.set(relPath, r0.text)
479
+ if (shadow) shadow.set(relPath, r0.text)
480
+ if (!manifest.has(relPath)) manifest.set(relPath, 1)
481
+ try { say(`⚠ ${relPath} was created by two participants at once — both versions were merged${r0.conflict ? ', and CONFLICT markers need resolving' : ''}.`) } catch { }
482
+ return
483
+ }
484
+ const anc = priorShadow ? priorShadow.get(relPath) : undefined
485
+ if (anc !== undefined && disk !== null) {
486
+ if (disk === anc) {
487
+ writeToDisk(relPath, docText)
488
+ bases.set(relPath, docText); forkBases.set(relPath, docText)
489
+ if (!snappedBase.has(relPath)) {
490
+ captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'room copy adopted on rejoin (local was unchanged)' })
491
+ snappedBase.add(relPath)
492
+ }
493
+ return
494
+ }
495
+ captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy before rejoin merge' })
496
+ const rj = icrMerge3(anc, disk, docText, relPath, icrlib)
497
+ if (rj.text !== docText) fe.doc.transact(() => applyDiff(yt, rj.text))
498
+ if (rj.text !== disk) writeToDisk(relPath, rj.text)
499
+ if (rj.conflict) conflicted.add(relPath)
500
+ bases.set(relPath, rj.text); forkBases.set(relPath, rj.text)
501
+ if (shadow) shadow.set(relPath, rj.text)
502
+ if (!manifest.has(relPath)) manifest.set(relPath, 1)
503
+ try { say(`⚠ ${relPath} changed locally while ${name} was disconnected — the local edit and the room copy were merged${rj.conflict ? ', and CONFLICT markers need resolving' : ''}. Pre-merge local copy is in the file's history.`) } catch { }
504
+ return
505
+ }
506
+ captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy at join (room copy adopted)' })
507
+ writeToDisk(relPath, docText)
508
+ bases.set(relPath, docText); forkBases.set(relPath, docText)
509
+ if (!snappedBase.has(relPath)) {
510
+ captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'adopted from room at join' })
511
+ snappedBase.add(relPath)
512
+ }
513
+ try { say(`↓ ${relPath} already existed in this room — ${name} adopted the room's copy. The local version before joining is saved in the file's history.`) } catch { }
514
+ return
515
+ }
516
+
517
+ const base = bases.has(relPath) ? bases.get(relPath) : disk
518
+ const fork = forkBases.has(relPath) ? forkBases.get(relPath) : base
519
+ if (origin === 'local') { noteCoEditing(relPath); markEditing(relPath) }
520
+
521
+ let res, reAdded = false
522
+ if (origin === 'local' && hasConflictMarkers(docText) && !hasConflictMarkers(disk)) {
523
+ res = { text: disk, conflict: false }
524
+ } else if (origin === 'local') {
525
+ noteIfRewrite(relPath, fork, disk)
526
+ const sc = summarizeChange(fork, disk)
527
+ const isCleanRewrite = sc.isRewrite && supports && supports(relPath) && languageFor && languageFor(relPath).parses(disk)
528
+ const theirsNew = changedRange(fork.split('\n'), docText.split('\n')).newLines.filter((l) => l.length)
529
+ const mineLines = new Set(disk.split('\n'))
530
+ const integrated = theirsNew.every((l) => mineLines.has(l))
531
+ if (integrated || (isCleanRewrite && !hasConflictMarkers(docText))) {
532
+ res = { text: disk, conflict: false }
533
+ } else {
534
+ res = icrMerge3(fork, disk, docText, relPath, icrlib)
535
+ reAdded = !res.conflict && res.text !== disk
536
+ }
537
+ } else {
538
+ res = icrMerge3(base, disk, docText, relPath, icrlib)
539
+ }
540
+
541
+ if (res && !res.conflict && fusionReason) {
542
+ const why = fusionReason(res.text, disk, docText, (supports && supports(relPath)) ? languageFor(relPath) : null)
543
+ if (why) {
544
+ conflicted.add(relPath)
545
+ if (icrWarned.get(relPath) !== why) {
546
+ icrWarned.set(relPath, why)
547
+ log(`[${name}] !! REFUSED a fused merge of ${relPath}: ${why}`)
548
+ try {
549
+ say(`[!] @all ${relPath}: a merge was REFUSED because it fused rather than merged — ${why}. Nothing was written: the room's copy and ${name}'s local copy are both intact and unchanged. Someone must reconcile them deliberately; a plain file write will merge against the other version again, hive_edit replaces it outright.`)
550
+ } catch { }
551
+ }
552
+ return
553
+ }
554
+ }
555
+
556
+ if (origin === 'local' && res.text !== docText) {
557
+ const sc = summarizeChange(docText, res.text)
558
+ captureSnapshot(relPath, docText, name, { force: sc.isRewrite, churn: `${sc.changedLines}/${sc.totalLines} lines` })
559
+ if (typeof lastLocalEdit === 'function') lastLocalEdit(relPath)
560
+ }
561
+ fe.doc.transact(() => applyDiff(yt, res.text))
562
+ if (origin === 'local') {
563
+ if (FORK_GATE) { if (res.text !== docText && attestHead) attestHead(relPath, res.text, docText) }
564
+ else if (res.text !== docText && stampProvenance) stampProvenance(relPath, docText, res.text)
565
+ }
566
+ if (res.text !== disk) writeToDisk(relPath, res.text)
567
+ known.add(relPath); bases.set(relPath, res.text)
568
+ if (origin === 'local') forkBases.set(relPath, res.text)
569
+ else if (!forkBases.get(relPath)) forkBases.set(relPath, res.text)
570
+
571
+ const hasMarkers = res.conflict || hasConflictMarkers(res.text)
572
+ if (hasMarkers && !conflicted.has(relPath)) {
573
+ conflicted.add(relPath)
574
+ log(`[${name}] ⚠ merge conflict in ${relPath} — kept BOTH versions with <<<<<<< markers`)
575
+ try { say(`⚠ MERGE CONFLICT in ${relPath} — it has <<<<<<< markers. Whoever owns it: resolve before continuing.`) } catch { }
576
+ } else if (!hasMarkers && conflicted.has(relPath)) {
577
+ conflicted.delete(relPath)
578
+ try { say(`✓ conflict in ${relPath} resolved.`) } catch { }
579
+ } else if (reAdded) {
580
+ log(`[${name}] protected ${relPath}: your edit was based on an older version — re-added changes that arrived since (nobody's work lost)`)
581
+ try { say(`↺ ${relPath}: ${name}'s edit was based on an older copy — kept the changes that landed in between (nothing lost).`) } catch { }
582
+ } else if (!hasMarkers && res.icr === 'rename') {
583
+ log(`[${name}] ICR merged ${relPath} (detected a rename and updated the call sites)`)
584
+ } else if (!hasMarkers && res.icr === 'structural') {
585
+ log(`[${name}] ICR merged ${relPath} structurally (both edits kept, syntax guaranteed)`)
586
+ } else if (!hasMarkers) {
587
+ log(`[${name}] merged ${relPath} (both edits kept)`)
588
+ }
589
+
590
+ if (res.icrWarning && icrWarned.get(relPath) !== res.icrWarning) {
591
+ icrWarned.set(relPath, res.icrWarning)
592
+ log(`[${name}] ⚠ ICR flag in ${relPath} — ${res.icrWarning}`)
593
+ try { say(`⚠ ICR flag in ${relPath}: ${res.icrWarning}. It merged, but review — this is a break a plain merge can't see.`) } catch { }
594
+ } else if (!res.icrWarning && icrWarned.has(relPath)) {
595
+ icrWarned.delete(relPath)
596
+ }
597
+
598
+ if (detectFork) detectFork(relPath)
599
+ }
600
+ }
601
+
602
+ // reconcile direct call
603
+ function reconcile(ctx, relPath, origin = 'local') {
604
+ return createReconciler(ctx)(relPath, origin)
605
+ }
606
+
607
+ module.exports = {
608
+ reconcile,
609
+ createReconciler,
610
+ applyDiff,
611
+ hasConflictMarkers,
612
+ stripConflictMarkers,
613
+ changedRange,
614
+ symbolsIn,
615
+ summarizeChange,
616
+ merge3,
617
+ lineHunks,
618
+ diff3Lines,
619
+ describeConflicts,
620
+ icrMerge3
621
+ }