hivecode-mcp 0.14.3 → 0.14.5

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.
package/icr.js CHANGED
@@ -644,7 +644,9 @@ function spliceUnits(baseText, baseUnits, order, textByKey, opts = {}) {
644
644
  // New unit: insert it exactly where the merge order dictated
645
645
  const val = textByKey.get(k)
646
646
  if (val != null) {
647
- out += (out.endsWith('\n') ? '' : '\n') + val + '\n'
647
+ // A unit that already ends with its newline (Python, Ruby, TOML, YAML) gets no second one: that
648
+ // put a blank line no author wrote after every edited top-level line (#19).
649
+ out += (out.endsWith('\n') ? '' : '\n') + val + (val.endsWith('\n') ? '' : '\n')
648
650
  }
649
651
  }
650
652
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hivecode-mcp",
3
- "version": "0.14.3",
3
+ "version": "0.14.5",
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",
@@ -42,6 +42,7 @@
42
42
  "hive-room-mode.js",
43
43
  "hive-room-watcher.js",
44
44
  "hive-mailbox.js",
45
+ "hive-seen.js",
45
46
  "hive-obligations.js",
46
47
  "hive-receipts.js",
47
48
  "hive-throttle.js",
@@ -138,7 +139,7 @@
138
139
  "test:substrate:authority": "node substrate-authority-test.js",
139
140
  "test:substrate:all": "node substrate-test.js && node substrate-live-test.js && node substrate-enforce-test.js && node substrate-authority-test.js",
140
141
  "test:recover": "node tests/recover-test.mjs",
141
- "test:mailbox": "node tests/mailbox-test.mjs && node tests/mcp-wait-test.mjs && node tests/mailbox-noise-test.mjs",
142
+ "test:mailbox": "node tests/mailbox-test.mjs && node tests/mcp-wait-test.mjs && node tests/mailbox-noise-test.mjs && node tests/mail-identity-test.mjs",
142
143
  "test:stophook": "node tests/stop-hook-test.mjs",
143
144
  "test:limits": "node tests/limits-test.mjs && node tests/rate-hold-test.mjs && node tests/persist-batch-test.mjs",
144
145
  "test:merge": "node tests/diff3-test.mjs && node tests/merge-balloon-test.mjs && node tests/icr-merge-test.js && node tests/icr-gap-comments-test.mjs && node tests/icr-comment-fuzz-test.mjs",
package/reconcile.cjs CHANGED
@@ -47,39 +47,38 @@ function hasConflictMarkers(text) {
47
47
  }
48
48
 
49
49
  // Strip and de-nest conflict markers to prevent exponential 2^N code ballooning
50
+ // Strip conflict markers, keeping the 'local' side (before the first =======), the 'theirs' side (after
51
+ // it), or 'all': every version in the block, in order, each distinct one once. A separator may carry a
52
+ // label ("======= Bob"): the 3-version blocks of a same-line race do.
50
53
  function stripConflictMarkers(text, prefer = 'local') {
51
54
  if (!text || !hasConflictMarkers(text)) return text || ''
52
55
  const lines = text.split('\n')
53
56
  const out = []
54
57
  let inConflict = false
55
- let inTheirs = false
56
- const localBlock = []
57
- const theirsBlock = []
58
+ let sections = []
58
59
 
59
60
  for (let i = 0; i < lines.length; i++) {
60
61
  const line = lines[i]
61
62
  if (/^<<<<<<< /.test(line)) {
62
63
  inConflict = true
63
- inTheirs = false
64
- localBlock.length = 0
65
- theirsBlock.length = 0
64
+ sections = [[]]
66
65
  continue
67
66
  }
68
- if (/^=======$/.test(line) && inConflict) {
69
- inTheirs = true
67
+ if (/^=======( |$)/.test(line) && inConflict) {
68
+ sections.push([])
70
69
  continue
71
70
  }
72
71
  if (/^>>>>>>> /.test(line) && inConflict) {
73
72
  inConflict = false
74
- out.push(...(prefer === 'theirs' ? theirsBlock : localBlock))
73
+ if (prefer === 'all') {
74
+ const seen = new Set()
75
+ for (const sec of sections) { const k = sec.join('\n'); if (seen.has(k)) continue; seen.add(k); out.push(...sec) }
76
+ } else if (prefer === 'theirs') out.push(...sections.slice(1).flat())
77
+ else out.push(...sections[0])
75
78
  continue
76
79
  }
77
- if (inConflict) {
78
- if (inTheirs) theirsBlock.push(line)
79
- else localBlock.push(line)
80
- } else {
81
- out.push(line)
82
- }
80
+ if (inConflict) sections[sections.length - 1].push(line)
81
+ else out.push(line)
83
82
  }
84
83
  return out.join('\n')
85
84
  }
@@ -193,22 +192,38 @@ function slideSpan(b, [bs, be, os, oe], src) {
193
192
  // changes conflict, as in git: two edits with no unchanged line between them are one decision). A
194
193
  // region only one side changed takes that side; both sides making the same change takes it once;
195
194
  // anything else is a conflict of THAT region only. Null when either diff is past the work bound.
196
- function diff3Lines(b, mine, theirs) {
195
+ //
196
+ // SIDE BY SIDE (sideBySide, the sync merge only; #17, 10 Nov 2026). Two edits that only TOUCH
197
+ // are applied side by side when both replace lines one for one (the same number of lines out as
198
+ // in). It is P0-F's footprint rule, and what the character CRDT does with the same two edits made at
199
+ // the same moment, so a save that lands a moment late no longer gets markers the on-time one did not
200
+ // (bigproject W1: README.md, five agents on adjacent lines). Overlap, an insertion or deletion at the
201
+ // edge, and a change of line count still conflict. git-clash and plain merge3 keep git's rule.
202
+ function diff3Lines(b, mine, theirs, sideBySide = false) {
197
203
  const hm = lineHunks(b, mine), ht = lineHunks(b, theirs)
198
204
  if (!hm || !ht) return null
199
205
  const sm = hm.map((h) => slideSpan(b, h, mine)), st = ht.map((h) => slideSpan(b, h, theirs))
206
+ const oneForOne = (h) => h[1] - h[0] === h[3] - h[2] // lineHunks has no empty hunk, so this is a real replacement
207
+ // Does a hunk starting at `s` join a region reaching `reach`? Overlap always; touching, unless both
208
+ // the hunk and every hunk ending at `reach` are one-for-one replacements (side by side).
209
+ const joins = (s, h, reach, edgeRep) => s < reach || (s === reach && !(sideBySide && edgeRep && oneForOne(h)))
200
210
  const out = []
201
211
  let conflict = false, pos = 0, i = 0, j = 0
202
212
  while (i < hm.length || j < ht.length) {
203
213
  const fromMine = j >= ht.length || (i < hm.length && hm[i][0] <= ht[j][0])
204
214
  const first = fromMine ? hm[i] : ht[j]
205
215
  let reach = fromMine ? sm[i++][1] : st[j++][1] // how far this region's edits could extend
216
+ let edgeRep = oneForOne(first) // every hunk ending at `reach` is one-for-one
206
217
  const rm = fromMine ? [first] : [], rt = fromMine ? [] : [first]
207
218
  const rs = first[0]
208
219
  let re = first[1]
220
+ const take = (h, span) => {
221
+ re = Math.max(re, h[1])
222
+ if (span[1] > reach) { reach = span[1]; edgeRep = oneForOne(h) } else if (span[1] === reach) edgeRep = edgeRep && oneForOne(h)
223
+ }
209
224
  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 }
225
+ if (i < hm.length && joins(sm[i][0], hm[i], reach, edgeRep)) { take(hm[i], sm[i]); rm.push(hm[i++]); continue }
226
+ if (j < ht.length && joins(st[j][0], ht[j], reach, edgeRep)) { take(ht[j], st[j]); rt.push(ht[j++]); continue }
212
227
  break
213
228
  }
214
229
  for (let x = pos; x < rs; x++) out.push(b[x])
@@ -248,8 +263,84 @@ function summarizeChange(base, next) {
248
263
  return { isRewrite, changedLines: churn, totalLines: total, symbols: symbolsIn(r.newLines.join('\n')) }
249
264
  }
250
265
 
266
+ // A LATE SAVE AGAINST A CONFLICT BLOCK (P0-F-9, 10 Nov 2026). The author edited a clean copy; the
267
+ // room's copy now holds one or more blocks. Every version the room holds is a whole file: block j
268
+ // read as section j (a block with fewer sections repeats its last). The late save is merged into
269
+ // EACH of them, a merge that conflicts giving both its sides, and the distinct results are written
270
+ // as ONE block per region where they differ. A block is never flattened into its sections side by
271
+ // side: that came out clean with lines no author wrote (`a = 1 / b = 1 / a = 2 / b = 2`, P0-F p0f),
272
+ // and 0.14.4's one-side strip silently dropped every other version. Null: the caller's old path
273
+ // (a section holding markers of its own, or a diff past its work bound).
274
+ function blockVersions(text) {
275
+ const lines = text.split('\n'), segs = [] // a segment: a plain line (string) or a block {head, foot, secs}
276
+ let blk = null
277
+ for (const line of lines) {
278
+ if (/^<<<<<<< /.test(line)) { if (blk) return null; blk = { head: line, secs: [[]] }; continue }
279
+ if (blk && /^=======( |$)/.test(line)) { blk.secs.push([]); continue }
280
+ if (blk && /^>>>>>>> /.test(line)) { blk.foot = line; segs.push(blk); blk = null; continue }
281
+ if (blk) blk.secs[blk.secs.length - 1].push(line)
282
+ else segs.push(line)
283
+ }
284
+ if (blk) return null // an unclosed block: not ours to read
285
+ const blocks = segs.filter((s) => typeof s !== 'string')
286
+ const k = Math.max(...blocks.map((b) => b.secs.length))
287
+ const versions = []
288
+ for (let j = 0; j < k; j++) versions.push(segs.flatMap((s) => (typeof s === 'string' ? [s] : s.secs[Math.min(j, s.secs.length - 1)])).join('\n'))
289
+ return { versions, blocks }
290
+ }
291
+ function mergeLateSave(base, mine, theirs, opts) {
292
+ const bv = blockVersions(theirs)
293
+ if (!bv) return null
294
+ const variants = [], lates = []
295
+ const add = (t, to = variants) => { if (!variants.some((v) => v.text === t.text) && !lates.some((v) => v.text === t.text)) to.push(t) }
296
+ for (const T of bv.versions) {
297
+ if (hasConflictMarkers(T)) return null
298
+ const r = merge3(base, mine, T, opts)
299
+ if (!r.conflict) { add({ text: r.text, mine: false }); continue }
300
+ // The room's versions first, in the block's order, so its header and footer still name its sections;
301
+ // the late save's versions join after all of them.
302
+ add({ text: stripConflictMarkers(r.text, 'theirs'), mine: false })
303
+ add({ text: stripConflictMarkers(r.text, 'local'), mine: true }, lates)
304
+ }
305
+ variants.push(...lates.filter((l) => !variants.some((v) => v.text === l.text)))
306
+ if (variants.length === 1) return { text: variants[0].text, conflict: false }
307
+ // Regions, in the first version's lines, where any other version differs.
308
+ const anchor = variants[0].text.split('\n')
309
+ const hunksOf = variants.map((v, i) => (i === 0 ? [] : lineHunks(anchor, v.text.split('\n'))))
310
+ if (hunksOf.some((h) => h === null)) return null
311
+ const spans = hunksOf.flat().map((h) => [h[0], h[1]]).sort((a, b) => a[0] - b[0] || a[1] - b[1])
312
+ const regions = []
313
+ for (const [s, e] of spans) {
314
+ const last = regions[regions.length - 1]
315
+ if (last && s <= last[1]) last[1] = Math.max(last[1], e)
316
+ else regions.push([s, e])
317
+ }
318
+ const heads = bv.blocks.map((b) => b.head), feet = bv.blocks.map((b) => b.foot.replace(/^>>>>>>> /, '').replace(/ \(\d+ versions\)$/, ''))
319
+ const out = []
320
+ let pos = 0
321
+ regions.forEach(([rs, re], ri) => {
322
+ for (let x = pos; x < rs; x++) out.push(anchor[x])
323
+ const secs = []
324
+ variants.forEach((v, vi) => {
325
+ const src = v.text.split('\n')
326
+ const sec = sideOfRegion(anchor, rs, re, hunksOf[vi].filter((h) => h[0] >= rs && h[1] <= re), src)
327
+ if (!secs.some((x) => x.join('\n') === sec.join('\n'))) secs.push(sec)
328
+ })
329
+ if (secs.length === 1) out.push(...secs[0])
330
+ else {
331
+ const who = feet[Math.min(ri, feet.length - 1)]
332
+ const lateName = String(opts.lateName || 'local (yours)').replace(/[\r\n]/g, ' ')
333
+ const late = variants.some((v) => v.mine) && !who.split(', ').includes(lateName) ? `, ${lateName}` : ''
334
+ out.push(heads[Math.min(ri, heads.length - 1)], ...secs[0], ...secs.slice(1).flatMap((x) => ['=======', ...x]), `>>>>>>> ${who}${late} (${secs.length} versions)`)
335
+ }
336
+ pos = re
337
+ })
338
+ for (let x = pos; x < anchor.length; x++) out.push(anchor[x])
339
+ return { text: out.join('\n'), conflict: true }
340
+ }
341
+
251
342
  // 3-way merge for the sync layer (disk <-> shared doc)
252
- function merge3(base, mine, theirs) {
343
+ function merge3(base, mine, theirs, opts = {}) {
253
344
  const norm = (s) => (s || '').replace(/\r\n/g, '\n')
254
345
  const nm0 = norm(mine), nt0 = norm(theirs), nb0 = norm(base)
255
346
  if (nm0 === nt0) return { text: nt0, conflict: false }
@@ -258,7 +349,17 @@ function merge3(base, mine, theirs) {
258
349
 
259
350
  const cleanBase = hasConflictMarkers(base) ? stripConflictMarkers(base, 'local') : (base || '')
260
351
  const cleanMine = hasConflictMarkers(mine) ? stripConflictMarkers(mine, 'local') : (mine || '')
261
- const cleanTheirs = hasConflictMarkers(theirs) ? stripConflictMarkers(theirs, 'theirs') : (theirs || '')
352
+ // A LATE SAVE: the author edited a clean copy and the room's copy now holds a conflict block. Keep
353
+ // EVERY version in the block: keeping one side dropped the other authors' work (P0-F,
354
+ // late-save-merge-test). Only then: when the base or the author's copy holds the block too, all three
355
+ // are stripped alike, as before, or the extra versions read as new lines every round and pile up
356
+ // (merge-balloon-test).
357
+ const lateSave = !hasConflictMarkers(base) && !hasConflictMarkers(mine)
358
+ if (lateSave && hasConflictMarkers(theirs)) {
359
+ const r = mergeLateSave(norm(base), norm(mine), norm(theirs), opts)
360
+ if (r) return r
361
+ }
362
+ const cleanTheirs = hasConflictMarkers(theirs) ? stripConflictMarkers(theirs, lateSave ? 'all' : 'theirs') : (theirs || '')
262
363
 
263
364
  const nm = norm(cleanMine), nt = norm(cleanTheirs), nb = norm(cleanBase)
264
365
  if (nm === nt) return { text: nt, conflict: false }
@@ -266,7 +367,7 @@ function merge3(base, mine, theirs) {
266
367
  if (nb === nm) return { text: nt, conflict: false }
267
368
 
268
369
  const b = nb.split('\n')
269
- const d3 = diff3Lines(b, nm.split('\n'), nt.split('\n'))
370
+ const d3 = diff3Lines(b, nm.split('\n'), nt.split('\n'), !!opts.sideBySide)
270
371
  if (d3) return { text: d3.lines.join('\n'), conflict: d3.conflict }
271
372
  // Past diff3's work bound (two sides that share almost nothing): one range per side.
272
373
  const mr = changedRange(b, nm.split('\n'))
@@ -616,6 +717,7 @@ module.exports = {
616
717
  merge3,
617
718
  lineHunks,
618
719
  diff3Lines,
720
+ blockVersions,
619
721
  describeConflicts,
620
722
  icrMerge3
621
723
  }