hivecode-mcp 0.11.6 → 0.12.0

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.
@@ -0,0 +1,59 @@
1
+ // hive-version.js — one place that knows what build this is.
2
+ //
3
+ // WHY THIS FILE EXISTS, and it is not tidiness.
4
+ //
5
+ // VERSION is load-bearing, not cosmetic. hive_join instructs every arriving agent to announce its
6
+ // version in chat, because two clients running different builds in one room corrupt each other's
7
+ // files with no error on either side — both writes succeed, both are green, and the disagreement
8
+ // surfaces only as lost bytes. That announcement is the ONLY channel that makes a mixed build
9
+ // visible from inside the room. So a version string is not a label; it is the input to the one
10
+ // detector we have.
11
+ //
12
+ // On 20 Sep 2026 three entry points were found reporting a version package.json does not contain:
13
+ // remote-mcp.js said '0.11.5' while live on the hosted relay, hive-mcp-http.js and hive-linear.js
14
+ // both said '1.0.0'. hive-mcp.js had already been fixed months earlier, with a comment explaining
15
+ // exactly why hardcoding it is dangerous — and that fix was never carried to the other three.
16
+ // A detector that lies is strictly worse than no detector, because it is trusted.
17
+ //
18
+ // HIV-P7 closed that by giving each of the four files its own runtime read. Which produced the
19
+ // next defect, and it was measured rather than predicted: with the reader duplicated four times,
20
+ // corrupting ONE copy's fallback string to '9.9.9-DRIFTED' left tests/version-truth-test.mjs at
21
+ // 16 passed, 0 failed. The property guards the version that gets REPORTED and says nothing about
22
+ // the mechanism that produces it, so three of four copies could rot in silence.
23
+ //
24
+ // This codebase has watched that happen before. The extension's reconcile() and the engine's were
25
+ // the same function once; they now share 34 lines and behave differently. Eight lines is not too
26
+ // small to drift — it is exactly small enough that nobody feels the need to check.
27
+ //
28
+ // So the fix is DELETION, not another assertion. One definition, imported by all four. The
29
+ // property becomes true by construction instead of by policing, and a fifth entry point written
30
+ // next year gets it right by importing rather than by remembering.
31
+ //
32
+ // TWO DECISIONS WORTH KEEPING:
33
+ //
34
+ // It READS package.json rather than being told. package.json always ships beside these files —
35
+ // in the repo and in the published tarball — so `new URL('./package.json', import.meta.url)`
36
+ // resolves in both. Anything else (a constant, a build step, a generated file) can disagree with
37
+ // the package the moment `npm version` runs, which is precisely the failure being fixed.
38
+ //
39
+ // IT NEVER THROWS. A version read is not worth a server that will not boot, and a server that
40
+ // refuses to start is a worse outcome than one that says it does not know which build it is. So
41
+ // an unreadable or malformed package.json yields '0.0.0-unknown' — a value that is obviously
42
+ // wrong to a human, will never match a real release, and cannot be mistaken for a true answer.
43
+ // Silence or an empty string could be read as "no mixed build here"; this cannot.
44
+
45
+ import fs from 'fs'
46
+
47
+ /** Recomputed never: a running process cannot change which build it is. */
48
+ export const VERSION = (() => {
49
+ try {
50
+ const v = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version
51
+ if (typeof v === 'string' && v) return v
52
+ } catch { /* fall through — see IT NEVER THROWS above */ }
53
+ return '0.0.0-unknown'
54
+ })()
55
+
56
+ /** The sentinel, exported so callers and tests can name it instead of retyping the literal. */
57
+ export const UNKNOWN_VERSION = '0.0.0-unknown'
58
+
59
+ export default VERSION
package/lang-js.js CHANGED
@@ -128,11 +128,20 @@ function bareName(node) {
128
128
  function declaredNames(src) {
129
129
  const s = new Set()
130
130
  for (const node of parse(src).body) {
131
- const nm = bareName(node); if (nm) s.add(nm)
132
- // Import locals count as declared names too — so if an import is ever dropped and its
131
+ let n = node
132
+ if (n.type === 'ExportNamedDeclaration' && n.declaration) n = n.declaration
133
+ if (n.type === 'VariableDeclaration') {
134
+ for (const d of n.declarations) bindingNames(d.id, s)
135
+ } else {
136
+ const nm = bareName(node); if (nm) s.add(nm)
137
+ }
138
+ // Import locals count as declared names too — so if an import is ever dropped and its
133
139
  // binding is still used, the dangling-reference check catches it (safety net for the
134
140
  // import merge). Keeps the never-emit-broken-code guarantee honest for imports.
135
141
  if (node.type === 'ImportDeclaration') for (const sp of node.specifiers) s.add(sp.local.name)
142
+ if (node.type === 'ExportNamedDeclaration' && !node.declaration && node.specifiers) {
143
+ for (const sp of node.specifiers) s.add(sp.exported.name || sp.exported.value || sp.local.name)
144
+ }
136
145
  }
137
146
  return s
138
147
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hivecode-mcp",
3
- "version": "0.11.6",
3
+ "version": "0.12.0",
4
4
  "description": "MCP server that lets any AI coding agent join a Hivecode room — humans and multiple AI agents edit one live codebase with folder-scoped permissions and approval gates.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,11 +28,18 @@
28
28
  },
29
29
  "files": [
30
30
  "hive-mcp.js",
31
+ "hive-version.js",
32
+ "hive-pr-staleness.js",
31
33
  "hive-mailbox.js",
32
34
  "hive-obligations.js",
33
35
  "hive-receipts.js",
34
36
  "hive-throttle.js",
35
37
  "hive-instructions.js",
38
+ "hive-activity.js",
39
+ "hive-relay-health.js",
40
+ "hive-privacy.js",
41
+ "docs/SELF_HOSTING.md",
42
+ "reconcile.cjs",
36
43
  "hive-busy.js",
37
44
  "hive-stop-hook.js",
38
45
  "hive-hook-install.js",
@@ -72,7 +79,13 @@
72
79
  "test:features": "node tests/features-test.mjs",
73
80
  "test:features:shape": "node tests/features-test.mjs --shape",
74
81
  "test:learnings": "node tests/learnings-test.mjs",
75
- "test:extension": "node tests/extension-parity-test.mjs",
82
+ "test:docs": "node tests/readme-promises-test.mjs",
83
+ "test:recreate": "node tests/blind-recreate-test.mjs",
84
+ "test:liveness": "node tests/agent-liveness-test.mjs && node tests/unreachable-recipient-test.mjs",
85
+ "build:ext": "node scripts/build-icr-cjs.cjs && node scripts/build-privacy-cjs.cjs",
86
+ "test:selfhost": "node tests/relay-pin-test.mjs && node tests/self-host-test.mjs && node tests/extension-privacy-parity-test.mjs",
87
+ "test:hardening": "node tests/genesis-claim-test.mjs && node tests/bare-root-write-test.mjs && node tests/version-truth-test.mjs && node tests/room-repo-drift-test.mjs && node tests/first-contact-refusal-test.mjs && node tests/open-file-cap-test.mjs && node tests/learnings-test.mjs && node tests/agent-liveness-test.mjs && node tests/unreachable-recipient-test.mjs && node tests/join-honesty-test.mjs && node tests/relay-diagnosis-test.mjs && node tests/blind-recreate-test.mjs && node tests/genesis-collision-test.mjs && node tests/relay-pin-test.mjs && node tests/self-host-test.mjs && node tests/extension-privacy-parity-test.mjs && node tests/readme-promises-test.mjs",
88
+ "test:extension": "node tests/extension-parity-test.mjs && node tests/extension-privacy-parity-test.mjs",
76
89
  "test:ceiling": "node tests/refused-visible-test.mjs && node tests/ceiling-no-data-loss-test.mjs",
77
90
  "test:identity": "node tests/identity-audit-test.mjs",
78
91
  "test:billing": "node tests/billing-test.mjs",
package/reconcile.cjs ADDED
@@ -0,0 +1,477 @@
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
+ // Best-effort "what was defined here" across common languages — for the board.
97
+ function symbolsIn(text) {
98
+ const names = new Set()
99
+ const add = (re) => { let m; while ((m = re.exec(text)) && names.size < 8) names.add(m[1]) }
100
+ add(/\bfunction\s+(\w+)/g)
101
+ add(/\bclass\s+(\w+)/g)
102
+ add(/\bdef\s+(\w+)/g)
103
+ add(/\b(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=/g)
104
+ return [...names].slice(0, 8)
105
+ }
106
+
107
+ // Classify a change as a small PATCH vs wholesale REWRITE.
108
+ function summarizeChange(base, next) {
109
+ const b = (base || '').split('\n')
110
+ const n = (next || '').split('\n')
111
+ if (!base) return { isRewrite: false, changedLines: n.length, totalLines: n.length, symbols: symbolsIn(next || '') }
112
+ const r = changedRange(b, n)
113
+ const churn = Math.max(r.endBase - r.startBase, r.newLines.length)
114
+ const total = Math.max(b.length, n.length)
115
+ const isRewrite = total > 0 && churn >= 4 && churn / total >= 0.5
116
+ return { isRewrite, changedLines: churn, totalLines: total, symbols: symbolsIn(r.newLines.join('\n')) }
117
+ }
118
+
119
+ // 3-way merge for the sync layer (disk <-> shared doc)
120
+ function merge3(base, mine, theirs) {
121
+ if (mine === theirs) return { text: mine, conflict: false }
122
+ if (base === theirs) return { text: mine, conflict: false }
123
+ if (base === mine) return { text: theirs, conflict: false }
124
+
125
+ const cleanBase = hasConflictMarkers(base) ? stripConflictMarkers(base, 'local') : (base || '')
126
+ const cleanMine = hasConflictMarkers(mine) ? stripConflictMarkers(mine, 'local') : (mine || '')
127
+ const cleanTheirs = hasConflictMarkers(theirs) ? stripConflictMarkers(theirs, 'theirs') : (theirs || '')
128
+
129
+ if (cleanMine === cleanTheirs) return { text: cleanMine, conflict: false }
130
+ if (cleanBase === cleanTheirs) return { text: cleanMine, conflict: false }
131
+ if (cleanBase === cleanMine) return { text: cleanTheirs, conflict: false }
132
+
133
+ const b = cleanBase.split('\n')
134
+ const mr = changedRange(b, cleanMine.split('\n'))
135
+ const tr = changedRange(b, cleanTheirs.split('\n'))
136
+ if (mr.endBase <= tr.startBase || tr.endBase <= mr.startBase) {
137
+ const out = b.slice()
138
+ for (const e of [mr, tr].sort((x, y) => y.startBase - x.startBase)) {
139
+ out.splice(e.startBase, e.endBase - e.startBase, ...e.newLines)
140
+ }
141
+ return { text: out.join('\n'), conflict: false }
142
+ }
143
+ const start = Math.min(mr.startBase, tr.startBase)
144
+ const end = Math.max(mr.endBase, tr.endBase)
145
+ const mineBlock = [...b.slice(start, mr.startBase), ...mr.newLines, ...b.slice(mr.endBase, end)]
146
+ const theirsBlock = [...b.slice(start, tr.startBase), ...tr.newLines, ...b.slice(tr.endBase, end)]
147
+ const out = [
148
+ ...b.slice(0, start),
149
+ '<<<<<<< local (yours)',
150
+ ...mineBlock,
151
+ '=======',
152
+ ...theirsBlock,
153
+ '>>>>>>> incoming (theirs)',
154
+ ...b.slice(end),
155
+ ]
156
+ return { text: out.join('\n'), conflict: true }
157
+ }
158
+
159
+ function describeConflicts(cs) {
160
+ return (cs || []).map((c) =>
161
+ c.startsWith('ref:') ? `'${c.slice(4)}' was removed or renamed but is still used`
162
+ : c.startsWith('fn:') ? `both sides changed function ${c.slice(3)}`
163
+ : c.startsWith('class:') ? `both sides changed class ${c.slice(6)}`
164
+ : `both sides changed ${c.replace(/^\w+:/, '')}`).join('; ')
165
+ }
166
+
167
+ function icrMerge3(base, mine, theirs, relPath, icrlib) {
168
+ const lm = merge3(base, mine, theirs)
169
+ if (!icrlib || !icrlib.supports || !icrlib.supports(relPath)) return lm
170
+ let r
171
+ try { r = icrlib.structuralMerge(base, mine, theirs, { filename: relPath }) } catch (e) { return lm }
172
+ if (r.status === 'auto') {
173
+ const renamed = r.renames && r.renames.length
174
+ return { text: r.text, conflict: false, icr: renamed ? 'rename' : 'structural', icrWarning: renamed ? `auto-applied rename ${r.renames.join(', ')}` : undefined }
175
+ }
176
+ if (r.status === 'semantic-conflict') return Object.assign({}, lm, { icrWarning: describeConflicts(r.conflicts) })
177
+ return lm
178
+ }
179
+
180
+ // createReconciler: binds to a session's maps, state, and callbacks, returning
181
+ // a reconcile(relPath, origin = 'local') function.
182
+ function createReconciler(ctx) {
183
+ const {
184
+ root,
185
+ name = 'anon',
186
+ SKIP = new Set(['HIVE_BOARD.md', 'HIVE_CHAT.md', 'HIVE_TASKS.md', 'HIVE_RULES.md', 'HIVE_MEMBERS.md', 'HIVE_FOR_AGENTS.md', '.hive.json']),
187
+ isIgnored = () => false,
188
+ canOpen = () => true,
189
+ canWrite = () => true,
190
+ readText = (full) => {
191
+ try {
192
+ const buf = fs.readFileSync(full)
193
+ if (buf.length > 1_000_000 || buf.includes(0)) return null
194
+ return buf.toString('utf8')
195
+ } catch { return null }
196
+ },
197
+ writeToDisk,
198
+ openFile,
199
+ touchFile,
200
+ fileDocs,
201
+ manifest,
202
+ bases,
203
+ forkBases,
204
+ known,
205
+ mtimes,
206
+ conflicted = new Set(),
207
+ icrWarned = new Map(),
208
+ snappedBase = new Set(),
209
+ preexisting = new Set(),
210
+ priorShadow,
211
+ shadow,
212
+ say = () => {},
213
+ log = () => {},
214
+ markEditing = () => {},
215
+ noteCoEditing = () => {},
216
+ noteIfRewrite = () => {},
217
+ captureSnapshot = () => {},
218
+ lastLocalEdit,
219
+ // Fork-gate & provenance
220
+ FORK_GATE = false,
221
+ captureLocal,
222
+ forkRecord,
223
+ forkBlock,
224
+ detectFork,
225
+ renderConflict,
226
+ adoptResolved,
227
+ stampProvenance,
228
+ attestHead,
229
+ fusionReason,
230
+ supports,
231
+ languageFor,
232
+ icrlib
233
+ } = ctx
234
+
235
+ return function reconcile(relPath, origin = 'local') {
236
+ if (SKIP.has(relPath) || isIgnored(relPath)) return
237
+ if (!canOpen(relPath)) return
238
+ const full = path.join(root, relPath)
239
+ const exists = fs.existsSync(full)
240
+ const disk = exists ? readText(full) : null
241
+ if (exists && disk === null) return
242
+
243
+ if (origin === 'local' && !canWrite(relPath)) {
244
+ const fe2 = fileDocs.get(relPath)
245
+ if (fe2 && fe2.synced) {
246
+ const dt = fe2.text.toString()
247
+ if (dt && dt !== disk) writeToDisk(relPath, dt)
248
+ }
249
+ return
250
+ }
251
+
252
+ let fe = fileDocs.get(relPath)
253
+ if (!fe) { if (disk === null) return; openFile(relPath); return }
254
+ if (!fe.synced) return
255
+ if (touchFile) touchFile(relPath)
256
+
257
+ if (FORK_GATE && captureLocal) captureLocal(relPath)
258
+
259
+ if (FORK_GATE && fe.conflict && forkRecord) {
260
+ const rec = forkRecord(fe)
261
+ if (rec) {
262
+ const block = forkBlock(fe, rec)
263
+ if (origin === 'local') {
264
+ if (disk === block) return
265
+ const fork0 = forkBases.get(relPath)
266
+ const sawMarkers = fork0 === block || hasConflictMarkers(fork0 || '')
267
+ if (sawMarkers && disk !== null && !hasConflictMarkers(disk)) {
268
+ fe.doc.transact(() => {
269
+ applyDiff(fe.text, disk)
270
+ for (const v of rec.versions) fe.forkmark.set(v.hash, 1)
271
+ for (const key of [...fe.conflict.keys()]) fe.conflict.delete(key)
272
+ })
273
+ if (stampProvenance) stampProvenance(relPath, block || '', disk)
274
+ conflicted.delete(relPath)
275
+ known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
276
+ try { say(`✓ conflict in ${relPath} resolved by ${name}.`) } catch { }
277
+ return
278
+ }
279
+ if (!sawMarkers && disk !== null && disk !== fork0) {
280
+ if (detectFork) detectFork(relPath)
281
+ return
282
+ }
283
+ if (renderConflict) renderConflict(relPath)
284
+ return
285
+ }
286
+ if (renderConflict) renderConflict(relPath)
287
+ return
288
+ }
289
+ if (disk !== null && hasConflictMarkers(disk) && !hasConflictMarkers(fe.text.toString())) {
290
+ if (adoptResolved) { adoptResolved(relPath); return }
291
+ }
292
+ }
293
+
294
+ const docText = fe.text.toString()
295
+ const noHistory = !bases.has(relPath) && !(priorShadow && priorShadow.has(relPath))
296
+ if (docText === '' && (!manifest.has(relPath) || (disk && noHistory))) {
297
+ if (disk === null) return
298
+ fe.doc.transact(() => applyDiff(fe.text, disk))
299
+ if (stampProvenance) stampProvenance(relPath, '', disk)
300
+ manifest.set(relPath, 1)
301
+ known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
302
+ if (shadow) shadow.set(relPath, disk)
303
+ try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
304
+ captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'created' })
305
+ snappedBase.add(relPath)
306
+ return
307
+ }
308
+
309
+ const yt = fe.text
310
+ if (disk === null) {
311
+ writeToDisk(relPath, docText)
312
+ bases.set(relPath, docText); forkBases.set(relPath, docText)
313
+ return
314
+ }
315
+
316
+ if (disk === docText) {
317
+ if (origin === 'local' && !known.has(relPath) && disk) markEditing(relPath)
318
+ known.add(relPath); bases.set(relPath, disk)
319
+ if (shadow) shadow.set(relPath, disk)
320
+ if (!forkBases.has(relPath) || origin === 'local') forkBases.set(relPath, disk)
321
+ if (!snappedBase.has(relPath) && disk) {
322
+ captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'baseline' })
323
+ snappedBase.add(relPath)
324
+ }
325
+ return
326
+ }
327
+
328
+ // FIRST-CONTACT ADOPT
329
+ if (!bases.has(relPath) && docText !== '' && disk !== docText) {
330
+ if (!preexisting.has(relPath)) {
331
+ const nl = (s) => (s === '' || s.endsWith('\n') ? s : s + '\n')
332
+ const r0 = icrMerge3('', nl(disk), nl(docText), relPath, icrlib)
333
+ if (r0.text !== docText) fe.doc.transact(() => applyDiff(yt, r0.text))
334
+ if (r0.text !== disk) writeToDisk(relPath, r0.text)
335
+ if (r0.conflict) conflicted.add(relPath)
336
+ bases.set(relPath, r0.text); forkBases.set(relPath, r0.text)
337
+ if (shadow) shadow.set(relPath, r0.text)
338
+ if (!manifest.has(relPath)) manifest.set(relPath, 1)
339
+ try { say(`⚠ ${relPath} was created by two participants at once — both versions were merged${r0.conflict ? ', and CONFLICT markers need resolving' : ''}.`) } catch { }
340
+ return
341
+ }
342
+ const anc = priorShadow ? priorShadow.get(relPath) : undefined
343
+ if (anc !== undefined && disk !== null) {
344
+ if (disk === anc) {
345
+ writeToDisk(relPath, docText)
346
+ bases.set(relPath, docText); forkBases.set(relPath, docText)
347
+ if (!snappedBase.has(relPath)) {
348
+ captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'room copy adopted on rejoin (local was unchanged)' })
349
+ snappedBase.add(relPath)
350
+ }
351
+ return
352
+ }
353
+ captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy before rejoin merge' })
354
+ const rj = icrMerge3(anc, disk, docText, relPath, icrlib)
355
+ if (rj.text !== docText) fe.doc.transact(() => applyDiff(yt, rj.text))
356
+ if (rj.text !== disk) writeToDisk(relPath, rj.text)
357
+ if (rj.conflict) conflicted.add(relPath)
358
+ bases.set(relPath, rj.text); forkBases.set(relPath, rj.text)
359
+ if (shadow) shadow.set(relPath, rj.text)
360
+ if (!manifest.has(relPath)) manifest.set(relPath, 1)
361
+ 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 { }
362
+ return
363
+ }
364
+ captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy at join (room copy adopted)' })
365
+ writeToDisk(relPath, docText)
366
+ bases.set(relPath, docText); forkBases.set(relPath, docText)
367
+ if (!snappedBase.has(relPath)) {
368
+ captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'adopted from room at join' })
369
+ snappedBase.add(relPath)
370
+ }
371
+ 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 { }
372
+ return
373
+ }
374
+
375
+ const base = bases.has(relPath) ? bases.get(relPath) : disk
376
+ const fork = forkBases.has(relPath) ? forkBases.get(relPath) : base
377
+ if (origin === 'local') { noteCoEditing(relPath); markEditing(relPath) }
378
+
379
+ let res, reAdded = false
380
+ if (origin === 'local' && hasConflictMarkers(docText) && !hasConflictMarkers(disk)) {
381
+ res = { text: disk, conflict: false }
382
+ } else if (origin === 'local') {
383
+ noteIfRewrite(relPath, fork, disk)
384
+ const sc = summarizeChange(fork, disk)
385
+ const isCleanRewrite = sc.isRewrite && supports && supports(relPath) && languageFor && languageFor(relPath).parses(disk)
386
+ const theirsNew = changedRange(fork.split('\n'), docText.split('\n')).newLines.filter((l) => l.length)
387
+ const mineLines = new Set(disk.split('\n'))
388
+ const integrated = theirsNew.every((l) => mineLines.has(l))
389
+ if (integrated || (isCleanRewrite && !hasConflictMarkers(docText))) {
390
+ res = { text: disk, conflict: false }
391
+ } else {
392
+ res = icrMerge3(fork, disk, docText, relPath, icrlib)
393
+ reAdded = !res.conflict && res.text !== disk
394
+ }
395
+ } else {
396
+ res = icrMerge3(base, disk, docText, relPath, icrlib)
397
+ }
398
+
399
+ if (res && !res.conflict && fusionReason) {
400
+ const why = fusionReason(res.text, disk, docText, (supports && supports(relPath)) ? languageFor(relPath) : null)
401
+ if (why) {
402
+ conflicted.add(relPath)
403
+ if (icrWarned.get(relPath) !== why) {
404
+ icrWarned.set(relPath, why)
405
+ log(`[${name}] !! REFUSED a fused merge of ${relPath}: ${why}`)
406
+ try {
407
+ 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.`)
408
+ } catch { }
409
+ }
410
+ return
411
+ }
412
+ }
413
+
414
+ if (origin === 'local' && res.text !== docText) {
415
+ const sc = summarizeChange(docText, res.text)
416
+ captureSnapshot(relPath, docText, name, { force: sc.isRewrite, churn: `${sc.changedLines}/${sc.totalLines} lines` })
417
+ if (typeof lastLocalEdit === 'function') lastLocalEdit(relPath)
418
+ }
419
+ fe.doc.transact(() => applyDiff(yt, res.text))
420
+ if (origin === 'local') {
421
+ if (FORK_GATE) { if (res.text !== docText && attestHead) attestHead(relPath, res.text, docText) }
422
+ else if (res.text !== docText && stampProvenance) stampProvenance(relPath, docText, res.text)
423
+ }
424
+ if (res.text !== disk) writeToDisk(relPath, res.text)
425
+ known.add(relPath); bases.set(relPath, res.text)
426
+ if (origin === 'local') forkBases.set(relPath, res.text)
427
+ else if (!forkBases.get(relPath)) forkBases.set(relPath, res.text)
428
+
429
+ const hasMarkers = res.conflict || hasConflictMarkers(res.text)
430
+ if (hasMarkers && !conflicted.has(relPath)) {
431
+ conflicted.add(relPath)
432
+ log(`[${name}] ⚠ merge conflict in ${relPath} — kept BOTH versions with <<<<<<< markers`)
433
+ try { say(`⚠ MERGE CONFLICT in ${relPath} — it has <<<<<<< markers. Whoever owns it: resolve before continuing.`) } catch { }
434
+ } else if (!hasMarkers && conflicted.has(relPath)) {
435
+ conflicted.delete(relPath)
436
+ try { say(`✓ conflict in ${relPath} resolved.`) } catch { }
437
+ } else if (reAdded) {
438
+ log(`[${name}] protected ${relPath}: your edit was based on an older version — re-added changes that arrived since (nobody's work lost)`)
439
+ try { say(`↺ ${relPath}: ${name}'s edit was based on an older copy — kept the changes that landed in between (nothing lost).`) } catch { }
440
+ } else if (!hasMarkers && res.icr === 'rename') {
441
+ log(`[${name}] ICR merged ${relPath} (detected a rename and updated the call sites)`)
442
+ } else if (!hasMarkers && res.icr === 'structural') {
443
+ log(`[${name}] ICR merged ${relPath} structurally (both edits kept, syntax guaranteed)`)
444
+ } else if (!hasMarkers) {
445
+ log(`[${name}] merged ${relPath} (both edits kept)`)
446
+ }
447
+
448
+ if (res.icrWarning && icrWarned.get(relPath) !== res.icrWarning) {
449
+ icrWarned.set(relPath, res.icrWarning)
450
+ log(`[${name}] ⚠ ICR flag in ${relPath} — ${res.icrWarning}`)
451
+ try { say(`⚠ ICR flag in ${relPath}: ${res.icrWarning}. It merged, but review — this is a break a plain merge can't see.`) } catch { }
452
+ } else if (!res.icrWarning && icrWarned.has(relPath)) {
453
+ icrWarned.delete(relPath)
454
+ }
455
+
456
+ if (detectFork) detectFork(relPath)
457
+ }
458
+ }
459
+
460
+ // reconcile direct call
461
+ function reconcile(ctx, relPath, origin = 'local') {
462
+ return createReconciler(ctx)(relPath, origin)
463
+ }
464
+
465
+ module.exports = {
466
+ reconcile,
467
+ createReconciler,
468
+ applyDiff,
469
+ hasConflictMarkers,
470
+ stripConflictMarkers,
471
+ changedRange,
472
+ symbolsIn,
473
+ summarizeChange,
474
+ merge3,
475
+ describeConflicts,
476
+ icrMerge3
477
+ }