hivecode-mcp 0.6.0 → 0.7.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.
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  <h1>Hivecode</h1>
2
2
 
3
- **Multiplayer for your AI coding agents — with real permissions.**
3
+ **Oversight and control for your AI coding agents.**
4
4
 
5
- Run humans and multiple AI agents on one live codebase. Folder-scoped access,
6
- approval gates, instant revoke. No git push/pull — edits sync in about a second.
7
- Open source.
5
+ Run AI agents on your codebase and actually stay in control: **watch** every edit
6
+ live, **fence** each agent to the folders it's allowed in, **approve** the risky
7
+ moves, and **undo** any agent instantly. No git push/pull. Open source.
8
8
 
9
9
  [**Install for VS Code**](https://marketplace.visualstudio.com/items?itemName=hivecode.hivecode)
10
10
  · [**Website**](https://livecode-xoss.onrender.com)
@@ -16,16 +16,21 @@ Open source.
16
16
 
17
17
  ## Why
18
18
 
19
- The moment you point more than one AI agent at a project, it gets messy: they
20
- overwrite each other's files, rebuild the same feature twice, and every agent can
21
- touch every file — secrets, configs, infra included. Today people juggle git
22
- worktrees and pray at merge time.
19
+ You can't trust an AI agent blindly. The faster it edits, the more you have to
20
+ check — and the moment you run more than one, it gets worse: they touch files they
21
+ shouldn't, overwrite each other, and you lose track of who changed what. Today's
22
+ answer is to isolate each agent in a git worktree and pray at merge time — which
23
+ doesn't help you *trust* the output, it just defers the mess.
23
24
 
24
- Hivecode is a shared **live room** where humans and agents edit one project
25
- together — but with real access control. You decide what each agent can reach, and
26
- the relay enforces it; an out-of-scope agent never even receives the bytes.
25
+ Hivecode adds the missing layer: **oversight and control.**
27
26
 
28
- It's **agent-neutral**: Claude Code, Cursor, Windsurf, or your own bot over MCP.
27
+ - **See everything** — a live Control Room (browser or phone) shows every agent and every file it touches, in real time.
28
+ - **Fence them in** — folder-scoped access the relay enforces; an out-of-scope agent never even receives the code.
29
+ - **Approve what matters** — gate risky work so nothing lands without your OK.
30
+ - **Undo anything** — instant rollback: restore any file, or revert *everything* one agent did.
31
+
32
+ It's **agent-neutral**: Claude Code, Cursor, Windsurf, or your own bot over MCP. No
33
+ git push/pull — edits sync in about a second.
29
34
 
30
35
  ---
31
36
 
@@ -47,24 +52,33 @@ Use **Manage access** to re-scope or revoke anyone, and **Leave Session** to sto
47
52
 
48
53
  ## Add an AI agent
49
54
 
50
- Agents join through the Hivecode MCP server ([`hive-mcp.js`](hive-mcp.js)). Point
51
- your agent's MCP config at it and hand it the join link; it appears in the room as
52
- an `ai` member, scoped to exactly the folders you invited it to. The agent gets
53
- tools to read chat, the task board, and members; to claim and complete tasks; to
54
- talk; and to wait for work. See [MCP.md](MCP.md) for setup.
55
+ Agents join through the Hivecode MCP server ([`hive-mcp.js`](hive-mcp.js)). Set it
56
+ up once, then just hand the agent a join link; it appears in the room as an `ai`
57
+ member, scoped to exactly the folders you invited it to.
58
+
59
+ - **[JOIN_WITH_AGENT.md](JOIN_WITH_AGENT.md)** — step-by-step for a *human*: set up MCP, then bring your agent in.
60
+ - **[AGENT_MANUAL.md](AGENT_MANUAL.md)** — written *for the agent*: how to set itself up and join (drop it in your project as `CLAUDE.md` so the agent reads it automatically).
61
+ - **[MCP.md](MCP.md)** — the full tool reference.
55
62
 
56
63
  ---
57
64
 
58
65
  ## What you get
59
66
 
60
- **Governance**
67
+ **Oversight & control** *(the point of the whole thing)*
68
+ - **Live Control Room** — watch every agent and every file it touches in real time,
69
+ from any browser or your phone. No more "what is it even doing right now?"
61
70
  - **Folder-scoped access** — invite an agent to `frontend/` only; it never receives
62
- anything else.
63
- - **Approval gates** — a human's request to *your* agent waits for your OK;
64
- agent-to-agent coordination flows automatically.
65
- - **Read-only roles** — reviewers see everything, change nothing.
71
+ anything else (relay-enforced, not trust-based).
72
+ - **Approval gates** — risky work waits for your OK; agent-to-agent coordination
73
+ flows automatically.
74
+ - **Instant rollback** — restore any file to an earlier point, or **revert
75
+ *everything* one agent did** in a click. Undo a rogue agent without touching the
76
+ rest of the team's work.
77
+ - **Mission control** — pause, resume, or reassign any agent; agents honor it
78
+ mid-task.
66
79
  - **Instant revoke** — cut access mid-session, enforced server-side on every
67
80
  reconnect (and it survives a relay restart).
81
+ - **Read-only roles** — reviewers see everything, change nothing.
68
82
 
69
83
  **Coordination**
70
84
  - **Built-in chat**, a **shared task board** (assign / complete), and **live
@@ -72,8 +86,6 @@ talk; and to wait for work. See [MCP.md](MCP.md) for setup.
72
86
  - **Co-editing heads-up** before two people touch the same file.
73
87
  - **Never lose work** — overlapping edits keep both versions with conflict markers;
74
88
  a stale rewrite can't silently delete another's lines.
75
- - **Mission control** — pause, resume, or reassign any agent; agents honor it
76
- mid-task.
77
89
 
78
90
  **Trust**
79
91
  - **Server-enforced auth** — signed tokens, algorithm-pinned, fail-closed.
package/hive-coord.js ADDED
@@ -0,0 +1,72 @@
1
+ // hive-coord.js — the Hive coordination layer (v0 prototype).
2
+ //
3
+ // The bet: AI coding agents work fast and BLIND — they collide because, unlike a human
4
+ // team, they have no way to coordinate BEFORE they edit. Merging (ICR) cleans up after a
5
+ // collision. This prevents the collision in the first place.
6
+ //
7
+ // The design is a real hive: NO central controller, no conductor, no extra agent. Order
8
+ // emerges from each agent following one local rule against a shared medium — exactly how
9
+ // bees coordinate via traces in the environment (stigmergy), and how millions of Ethernet
10
+ // devices share one wire with no master (carrier-sense / collision-detect / backoff).
11
+ //
12
+ // The shared medium is a CRDT map living IN the codebase doc (a Y.Map in Hivecode; a plain
13
+ // Map in tests). Each region of work (a file, or a unit) can hold one CLAIM:
14
+ // { by: agentId, intent: string, at: time, ttl: ms }
15
+ //
16
+ // An agent's entire protocol — no coordinator anywhere:
17
+ // 1. SENSE — read the shared map. Is my target region claimed and fresh?
18
+ // 2. FLOW — if taken, move to open space (pick another free region). This is the
19
+ // emergent load-balancing: agents spread across the codebase on their own.
20
+ // 3. CLAIM — write my claim to the free region.
21
+ // 4. VERIFY — re-read (collision-detect): if a concurrent claim beat mine, BACK OFF and
22
+ // retry later. (Like Ethernet: listen, send, detect collision, back off.)
23
+ // 5. RELEASE — when done, delete my claim and (optionally) leave a "done" trace.
24
+ //
25
+ // EVAPORATION: claims carry a TTL. A crashed or stalled agent's claim simply expires, and
26
+ // the region frees itself — no central garbage collector, no deadlock. (The pheromone fades.)
27
+ //
28
+ // `shared` is any object with get(key)/set(key,val)/delete(key)/entries() — so the same
29
+ // code runs on a JS Map (tests) and a Yjs Y.Map (production). `now` is injectable so tests
30
+ // can drive a deterministic virtual clock.
31
+
32
+ export function makeCoordinator(shared, self, opts = {}) {
33
+ const ttl = opts.ttl != null ? opts.ttl : 5000
34
+ const now = opts.now || (() => Date.now())
35
+ const expired = (c) => !c || (now() - c.at) >= c.ttl
36
+
37
+ // SENSE: the live, non-expired claim on a region (or null if open).
38
+ const sense = (region) => { const c = shared.get(region); return expired(c) ? null : c }
39
+
40
+ // Is this region open to ME? (free, expired, or already mine)
41
+ const free = (region) => { const c = shared.get(region); return expired(c) || c.by === self }
42
+
43
+ const claim = (region, intent) => { shared.set(region, { by: self, intent, at: now(), ttl }) }
44
+ const release = (region) => { const c = shared.get(region); if (c && c.by === self) shared.delete(region) }
45
+
46
+ // ACQUIRE one region: sense → claim → verify. Returns true iff I now hold it.
47
+ // The verify step is the collision-detector: if a concurrent writer won the region,
48
+ // I see their id on re-read and concede.
49
+ const acquire = (region, intent) => {
50
+ const c = shared.get(region)
51
+ if (c && !expired(c) && c.by !== self) return false // sensed an owner → defer
52
+ claim(region, intent)
53
+ const after = shared.get(region)
54
+ return !(after && after.by !== self) // verify: did my claim survive?
55
+ }
56
+
57
+ // FLOW to open space: try my preferred regions in order, take the first I can acquire.
58
+ // This is what makes the swarm spread out with nobody assigning work.
59
+ const acquireAny = (candidates, intent) => {
60
+ for (const r of candidates) if (acquire(r, intent)) return r
61
+ return null
62
+ }
63
+
64
+ // Everything currently claimed (live), as a snapshot — for awareness / a Control Room.
65
+ const board = () => {
66
+ const out = []
67
+ for (const [region, c] of shared.entries()) if (!expired(c)) out.push({ region, ...c })
68
+ return out
69
+ }
70
+
71
+ return { sense, free, claim, release, acquire, acquireAny, board }
72
+ }
package/hive-mcp.js CHANGED
@@ -142,6 +142,12 @@ const TOOLS = [
142
142
  inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'how many recent messages (default 30)' } } } },
143
143
  { name: 'hive_read_board', description: 'Read recent whole-file rewrites (who rewrote what, which symbols). Read this before editing a file — if it was just rewritten, re-read the file first.',
144
144
  inputSchema: { type: 'object', properties: {} } },
145
+ { name: 'hive_claim', description: 'CLAIM a file (or region) BEFORE you edit it, so other agents flow around you instead of colliding. Returns whether you got it — if false, someone else holds it: work on something else. Claims auto-expire, so call hive_release when done (or it frees itself).',
146
+ inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'the file path (or region id) you want to edit' }, intent: { type: 'string', description: 'short note on what you intend to do' } }, required: ['region'] } },
147
+ { name: 'hive_release', description: 'Release a file/region you claimed, so other agents can take it. Call this when you finish editing it.',
148
+ inputSchema: { type: 'object', properties: { region: { type: 'string', description: 'the file path (or region id) to release' } }, required: ['region'] } },
149
+ { name: 'hive_claims', description: 'See what every agent is currently working on (the live claim board). Read this to pick open work and avoid taken regions.',
150
+ inputSchema: { type: 'object', properties: {} } },
145
151
  { name: 'hive_members', description: 'List who is currently in the room (humans and AI agents).', inputSchema: { type: 'object', properties: {} } },
146
152
  { name: 'hive_status', description: 'Show the current session (room, relay, folder, name).', inputSchema: { type: 'object', properties: {} } },
147
153
  { name: 'hive_leave', description: 'Leave the room and stop syncing.', inputSchema: { type: 'object', properties: {} } },
@@ -186,6 +192,15 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
186
192
  case 'hive_approve': { const r = requireSession().hive.decide(String(args.id), true); return r.ok ? text(`approved ${args.id}`) : err(r.error) }
187
193
  case 'hive_deny': { const r = requireSession().hive.decide(String(args.id), false); return r.ok ? text(`denied ${args.id}`) : err(r.error) }
188
194
  case 'hive_complete': { const r = requireSession().hive.complete(String(args.id), String(args.note || '')); return r.ok ? text(`completed ${args.id}`) : err(r.error) }
195
+ case 'hive_claim': {
196
+ const region = String(args.region || ''); if (!region) return err('need a region (file path)')
197
+ const got = requireSession().hive.claim(region, String(args.intent || 'edit'))
198
+ if (got) return text(`claimed ${region} — it's yours. Edit it, then call hive_release.`)
199
+ const c = requireSession().hive.senseClaim(region)
200
+ return text(`could NOT claim ${region}${c ? ` — held by ${c.by}${c.intent ? ` (${c.intent})` : ''}` : ''}. Work on something else; check hive_claims for open files.`)
201
+ }
202
+ case 'hive_release': { const region = String(args.region || ''); if (!region) return err('need a region'); requireSession().hive.release(region); return text(`released ${region}`) }
203
+ case 'hive_claims': { const b = requireSession().hive.claimsBoard(); return text(b.length ? b.map((c) => `${c.region} — ${c.by}${c.intent ? ` (${c.intent})` : ''}`).join('\n') : '(nothing claimed right now — all open)') }
189
204
  case 'hive_members': { const now = Date.now(); return text(requireSession().hive.members().map((m) => `${m.name} (${m.kind})${m.editing && now - m.editing.at < 15000 ? ` — editing ${m.editing.file}` : ''}`).join('\n') || '(none)') }
190
205
  case 'hive_status': { const s = requireSession(); return text({ room: s.room, relay: s.relay, dir: s.dir, name: s.name }) }
191
206
  case 'hive_leave': { requireSession().hive.stop(); session = null; return text('left the room') }
package/icr-merge.js ADDED
@@ -0,0 +1,58 @@
1
+ // icr-merge.js — the bridge that brings ICR into the live product, SAFELY.
2
+ //
3
+ // Hivecode's reconcile() merges concurrent edits with merge3() (a line-level 3-way
4
+ // merge). merge3 is convergent: across many peers it always settles on one canonical
5
+ // text. ICR's structural merge produces a DIFFERENT canonical form (it may reorder or
6
+ // reformat). Mixing two merge algorithms in a live multi-peer sync means the same logical
7
+ // content has two byte-forms, and peers ping-pong between them — which corrupts files
8
+ // (duplicated/*dropped* lines). So we do NOT change merge bytes with ICR here.
9
+ //
10
+ // Instead ICR runs in ADVISORY mode: merge3 still produces the bytes (full convergence
11
+ // preserved, identical to pre-ICR behavior), and ICR runs alongside purely to DETECT the
12
+ // meaning-level problems a line merge can't see — a dangling reference, a rename, a
13
+ // same-declaration clash — and surface them as a warning on the activity feed. This ships
14
+ // ICR's core value (catching semantic breaks) with zero divergence risk.
15
+ //
16
+ // ICR's full structural/rename AUTO-MERGE lives in the library (icr.js) and is proven by
17
+ // the test suite + fuzzer; turning it on in the product is a later step that needs a
18
+ // format-preserving, single-canonical-form merge so peers can't diverge.
19
+
20
+ import { structuralMerge, supports } from './icr.js'
21
+ import { merge3 } from './core.js'
22
+
23
+ // Turn ICR's machine conflict keys into a human sentence for the activity feed / chat.
24
+ function describeConflicts(conflicts) {
25
+ return (conflicts || []).map((c) => {
26
+ if (c.startsWith('ref:')) return `'${c.slice(4)}' was removed or renamed but is still used`
27
+ if (c.startsWith('fn:')) return `both sides changed function ${c.slice(3)}`
28
+ if (c.startsWith('class:')) return `both sides changed class ${c.slice(6)}`
29
+ if (c.startsWith('var:')) return `both sides changed ${c.slice(4)}`
30
+ return `both sides changed ${c}`
31
+ }).join('; ')
32
+ }
33
+
34
+ // Same shape as merge3 — { text, conflict } — plus an `icr` tag ('structural'|'rename')
35
+ // and an optional `icrWarning`. ICR's format-preserving merge is convergent (symmetric +
36
+ // fixed-point, verified by icr-converge-test.js), so its 'auto' result is used as the
37
+ // merged bytes for supported files; merge3 covers conflicts, fallbacks, and other languages.
38
+ export function icrMerge3(base, mine, theirs, relPath) {
39
+ const lm = merge3(base, mine, theirs)
40
+ if (!supports(relPath)) return lm
41
+
42
+ let r
43
+ try { r = structuralMerge(base, mine, theirs, { filename: relPath }) }
44
+ catch { return lm } // ICR must never break a sync
45
+
46
+ if (r.status === 'auto') {
47
+ const renamed = r.renames && r.renames.length
48
+ return {
49
+ text: r.text, conflict: false, icr: renamed ? 'rename' : 'structural',
50
+ icrWarning: renamed ? `auto-applied rename ${r.renames.join(', ')} — call sites updated` : undefined,
51
+ }
52
+ }
53
+ // A meaning-level conflict the line merge can't represent (dangling ref / clash): keep
54
+ // the safe line-merge bytes, but surface what ICR caught so it isn't shipped silently.
55
+ if (r.status === 'semantic-conflict')
56
+ return { ...lm, icrWarning: describeConflicts(r.conflicts) }
57
+ return lm // 'fallback'
58
+ }
package/icr.js ADDED
@@ -0,0 +1,267 @@
1
+ // ICR — Intent-aware Code Replication (v1 proof-of-concept).
2
+ //
3
+ // The first real step from Hivecode-the-tool toward a FOUNDATIONAL primitive:
4
+ // merge code by STRUCTURE and INTENT, not characters, with a hard guarantee — never
5
+ // emit code more broken than its inputs.
6
+ //
7
+ // Why this matters: CRDTs (Yjs) merge characters. They will happily fuse two edits
8
+ // into syntactically-valid garbage, because they have no idea what code MEANS. ICR
9
+ // parses the code, merges at the level of declarations (functions / classes / vars),
10
+ // descends INTO a declaration both sides touched (so disjoint edits inside one function
11
+ // still merge), detects when two authors changed the SAME thing (a real conflict), and
12
+ // validates that the merged result still parses — falling back safely if it wouldn't.
13
+ //
14
+ // On top of structure it adds an INTENT layer:
15
+ // • RENAME DETECTION — a declaration gone from base whose identical-bodied twin appears
16
+ // under a new name is recognized as a rename; stale call sites are rewritten so the
17
+ // other agent's fresh calls keep working. Nothing else does this.
18
+ // • DANGLING REFERENCE — a declaration removed/renamed but still referenced is flagged
19
+ // as a semantic conflict even though the code parses. CRDTs/git/plain merge miss it.
20
+ //
21
+ // LANGUAGE-AGNOSTIC BY DESIGN: this file is the engine. Everything that knows about a
22
+ // specific language (how to parse, what a declaration is, how to find references) lives
23
+ // behind a provider (see lang-js.js). Adding Python/Go/Rust = writing another provider
24
+ // and registerLanguage()-ing it; the merge logic below never changes. JavaScript via
25
+ // `acorn` is simply the first provider. The production path swaps in tree-sitter
26
+ // providers for every language behind this same interface.
27
+
28
+ import { javascript } from './lang-js.js'
29
+
30
+ // --- language registry ----------------------------------------------------------
31
+ const LANGUAGES = [javascript]
32
+
33
+ // Register an additional language provider. Must expose the provider contract:
34
+ // { id, exts:[...], parses, units, declaredNames, usedIdentifiers, declBody,
35
+ // renameRefs, fnParts }. Newest wins on extension clashes.
36
+ export function registerLanguage(provider) { LANGUAGES.unshift(provider) }
37
+
38
+ function extname(p) { const m = /\.[^.\/\\]+$/.exec(p || ''); return m ? m[0].toLowerCase() : '' }
39
+
40
+ // The provider for a filename, or null if no registered language claims its extension.
41
+ export function languageFor(filename) {
42
+ const e = extname(filename)
43
+ return LANGUAGES.find((l) => l.exts.includes(e)) || null
44
+ }
45
+
46
+ // Can ICR merge this file structurally? (Callers gate on this before trying.)
47
+ export function supports(filename) { return languageFor(filename) != null }
48
+
49
+ // --- generic merge primitives (language-independent: operate on {key,text}) -----
50
+ const mapOf = (us) => new Map(us.map((u) => [u.key, u.text]))
51
+ function dedupeOrder(keys) { const seen = new Set(), out = []; for (const k of keys) if (!seen.has(k)) { seen.add(k); out.push(k) } return out }
52
+
53
+ // For a given unit, choose which version survives: whoever CHANGED it wins; if neither
54
+ // changed it, keep the base.
55
+ function pick(k, B, A, Bb) {
56
+ const base = B.has(k) ? B.get(k) : null
57
+ const a = A.has(k) ? A.get(k) : null
58
+ const b = Bb.has(k) ? Bb.get(k) : null
59
+ if (a != null && a !== base) return a
60
+ if (b != null && b !== base) return b
61
+ return base != null ? base : (a != null ? a : b)
62
+ }
63
+
64
+ // Core 3-way merge over a KEYED list of units (works at file level AND, recursively,
65
+ // inside a single declaration). Returns { conflicts:[...], parts:[...text] }.
66
+ // When both sides change the SAME key into different things, it first tries to descend
67
+ // INTO that unit (finer granularity) — only a clash it can't resolve becomes a conflict.
68
+ function mergeKeyed(lang, baseU, aU, bU) {
69
+ const B = mapOf(baseU), A = mapOf(aU), Bb = mapOf(bU)
70
+ const keys = dedupeOrder([...baseU.map((u) => u.key), ...aU.map((u) => u.key), ...bU.map((u) => u.key)])
71
+
72
+ const conflicts = [], resolved = new Map()
73
+ for (const k of keys) {
74
+ const bs = B.has(k) ? B.get(k) : null
75
+ const as_ = A.has(k) ? A.get(k) : null
76
+ const bbs = Bb.has(k) ? Bb.get(k) : null
77
+ const changedA = as_ !== bs, changedB = bbs !== bs
78
+ if (changedA && changedB && as_ !== bbs) {
79
+ // Both touched the same unit, differently. Try finer-grained inner merge first.
80
+ if (bs != null && as_ != null && bbs != null) {
81
+ const inner = tryInnerMerge(lang, bs, as_, bbs)
82
+ if (inner != null) { resolved.set(k, inner); continue }
83
+ }
84
+ conflicts.push(k)
85
+ }
86
+ }
87
+ if (conflicts.length) return { conflicts, parts: [] }
88
+
89
+ // No unresolved clashes → decide which keys survive, honoring one-sided deletions.
90
+ const survives = new Set()
91
+ for (const k of keys) {
92
+ const inBase = B.has(k), inA = A.has(k), inB2 = Bb.has(k)
93
+ if (inBase && (!inA || !inB2)) {
94
+ const deletedByA = !inA, deletedByB = !inB2
95
+ const otherUnchangedFromBase =
96
+ (deletedByA && (!inB2 || Bb.get(k) === B.get(k))) ||
97
+ (deletedByB && (!inA || A.get(k) === B.get(k)))
98
+ if (otherUnchangedFromBase) continue // accept the deletion
99
+ }
100
+ survives.add(k)
101
+ }
102
+
103
+ // CONVERGENCE: the output order must be IDENTICAL no matter which side is `a` vs `b`,
104
+ // or two peers compute different text and never settle (then re-merging the divergence
105
+ // corrupts the file). So: surviving base units keep their base order; units added by a
106
+ // side are appended in a canonical (sorted) order — deterministic and symmetric.
107
+ const baseOrder = dedupeOrder(baseU.map((u) => u.key))
108
+ const baseSet = new Set(baseOrder)
109
+ const final = []
110
+ for (const k of baseOrder) if (survives.has(k)) final.push(k)
111
+ for (const k of [...survives].filter((k) => !baseSet.has(k)).sort()) final.push(k)
112
+
113
+ // Assemble, recording PROVENANCE per surviving unit: who authored the version we kept
114
+ // ('a' / 'b' changed it, 'both' if we merged inside it, 'base' if unchanged).
115
+ const parts = [], provenance = [], order = [], textByKey = new Map()
116
+ for (const k of final) {
117
+ let text, from
118
+ if (resolved.has(k)) { text = resolved.get(k); from = 'both' }
119
+ else {
120
+ text = pick(k, B, A, Bb)
121
+ const bs = B.has(k) ? B.get(k) : null, as_ = A.has(k) ? A.get(k) : null, bbs = Bb.has(k) ? Bb.get(k) : null
122
+ from = (as_ != null && as_ !== bs) ? 'a' : (bbs != null && bbs !== bs) ? 'b' : 'base'
123
+ }
124
+ if (text != null) { parts.push(text); provenance.push({ key: k, from }); order.push(k); textByKey.set(k, text) }
125
+ }
126
+ return { conflicts: [], parts, provenance, order, textByKey }
127
+ }
128
+
129
+ // FINER GRANULARITY: both sides edited the same function OR class. If the signature
130
+ // matches and they edited DIFFERENT inner units (statements / class members), merge those
131
+ // recursively rather than declaring the whole declaration a conflict. Returns merged
132
+ // text, or null meaning "couldn't safely merge inside — treat as a real conflict."
133
+ function tryInnerMerge(lang, baseText, aText, bText) {
134
+ // Language-specific same-key merges first (e.g. unioning import specifiers).
135
+ if (lang.mergeUnit) {
136
+ const t = lang.mergeUnit(baseText, aText, bText)
137
+ if (t != null && lang.parses(t)) return t
138
+ }
139
+ if (!lang.splitUnit) return null
140
+ const pb = lang.splitUnit(baseText), pa = lang.splitUnit(aText), pbb = lang.splitUnit(bText)
141
+ if (!pb || !pa || !pbb) return null
142
+ if (pa.sig.trim() !== pbb.sig.trim()) return null // signature itself changed → real conflict
143
+ const m = mergeKeyed(lang, pb.units, pa.units, pbb.units)
144
+ if (m.conflicts.length) return null
145
+ const text = pa.sig + pa.open + m.parts.join(pa.join) + pa.close
146
+ const valid = lang.parsesUnit || lang.parses // accept class-member fragments when supported
147
+ return valid(text) ? text : null
148
+ }
149
+
150
+ // FORMAT-PRESERVING assembly: rebuild the merged file by splicing the merged units back
151
+ // into the BASE text at their original byte ranges. Unchanged units — and all the
152
+ // whitespace/comments BETWEEN units — survive verbatim; only changed units carry the
153
+ // editing side's bytes, and genuinely new units are appended. This is what stops ICR from
154
+ // reformatting code it merges. Deterministic and symmetric, so peers converge.
155
+ function spliceUnits(baseText, baseUnits, order, textByKey) {
156
+ const baseKeySet = new Set(baseUnits.map((u) => u.key))
157
+ const surviving = new Set(order.filter((k) => baseKeySet.has(k)))
158
+ const emitted = new Set()
159
+ let out = '', pos = 0
160
+ for (const u of baseUnits) {
161
+ out += baseText.slice(pos, u.start) // gap before this unit (comments/blank lines), verbatim
162
+ if (surviving.has(u.key) && !emitted.has(u.key)) { out += textByKey.get(u.key); emitted.add(u.key) }
163
+ pos = u.end
164
+ }
165
+ const added = order.filter((k) => !baseKeySet.has(k))
166
+ if (added.length) {
167
+ const head = out.replace(/\s*$/, '')
168
+ out = (head ? head + '\n\n' : '') + added.map((k) => textByKey.get(k)).join('\n\n') + '\n'
169
+ } else {
170
+ out += baseText.slice(pos) // trailing whitespace (final newline) verbatim
171
+ }
172
+ return out.replace(/^\n+/, '') // drop orphan leading blank lines left by a deleted first unit
173
+ }
174
+
175
+ // --- public API -----------------------------------------------------------------
176
+
177
+ // Does this source parse under the given (or default JS) language?
178
+ export function parses(src, lang = javascript) { return lang.parses(src) }
179
+
180
+ // 3-way STRUCTURAL + INTENT merge of `a` and `b` against common ancestor `base`.
181
+ // opts: { lang } a provider, or { filename } to pick one by extension (defaults to JS).
182
+ // Returns { status, text, conflicts, renames? }:
183
+ // 'auto' — clean merge; `text` is valid, parseable code.
184
+ // 'semantic-conflict' — same declaration changed both sides, or a dangling reference;
185
+ // `conflicts` names them (e.g. ['fn:login'] or ['ref:helper']).
186
+ // 'fallback' — couldn't merge safely (unparseable input, or the merge wouldn't
187
+ // parse, or unsupported language); caller keeps both. Never broken.
188
+ export function structuralMerge(base, a, b, opts = {}) {
189
+ const lang = opts.lang || (opts.filename ? languageFor(opts.filename) : javascript) || javascript
190
+
191
+ if (!lang.parses(base) || !lang.parses(a) || !lang.parses(b))
192
+ return { status: 'fallback', text: null, conflicts: [], reason: 'unparseable input' }
193
+
194
+ // FIXED-POINT fast path: once two peers agree on a text T, re-merging must return T
195
+ // unchanged. This is what makes the live sync settle. Both agree ⇒ nothing to check.
196
+ if (a === b) return { status: 'auto', text: a, conflicts: [], renames: [], provenance: [] }
197
+
198
+ const authors = opts.authors || {}
199
+ const attribute = (prov) => (prov || []).map((p) => ({ unit: p.key, author: authors[p.from] || p.from }))
200
+
201
+ // Determine the structurally-merged text + provenance. A one-sided change takes that
202
+ // side verbatim (format perfectly preserved); otherwise do the keyed 3-way merge and
203
+ // splice it back into base. Either way the INTENT layer below still runs, so a deletion
204
+ // that leaves a dangling reference is caught even when only one side changed.
205
+ let text, provenance
206
+ if (a === base) { text = b; provenance = [] }
207
+ else if (b === base) { text = a; provenance = [] }
208
+ else {
209
+ const baseUnits = lang.units(base)
210
+ const merge = mergeKeyed(lang, baseUnits, lang.units(a), lang.units(b))
211
+ if (merge.conflicts.length) return { status: 'semantic-conflict', text: null, conflicts: merge.conflicts }
212
+ // Prefer the format-preserving splice (units carry byte ranges); fall back to a plain
213
+ // join for languages whose units don't expose ranges.
214
+ const canSplice = baseUnits.every((u) => typeof u.start === 'number')
215
+ text = canSplice ? spliceUnits(base, baseUnits, merge.order, merge.textByKey) : merge.parts.join('\n\n') + '\n'
216
+ provenance = merge.provenance
217
+ }
218
+
219
+ // THE GUARANTEE: the merged result must parse, or we refuse it.
220
+ if (!lang.parses(text)) return { status: 'fallback', text: null, conflicts: [], reason: 'merge would not parse' }
221
+
222
+ // RENAME DETECTION (intent): a declaration gone from base whose identical-bodied twin
223
+ // appears under a new name is a rename — rewrite stale call sites to the new name.
224
+ let merged = text
225
+ const baseNames = lang.declaredNames(base)
226
+ let mergedNames = lang.declaredNames(merged)
227
+ let removed = [...baseNames].filter((n) => !mergedNames.has(n))
228
+ const renames = []
229
+ const rename = lang.renameFreeRefs || lang.renameRefs // prefer scope-aware rewriting
230
+ if (removed.length && lang.declBody && rename) {
231
+ const added = [...mergedNames].filter((n) => !baseNames.has(n))
232
+ const claimed = new Set()
233
+ for (const oldName of removed) {
234
+ const oldBody = lang.declBody(base, oldName)
235
+ if (oldBody == null) continue
236
+ const match = added.find((n) => !claimed.has(n) && lang.declBody(merged, n) === oldBody)
237
+ if (match) { renames.push([oldName, match]); claimed.add(match) }
238
+ }
239
+ let rewritten = merged
240
+ // Rewrite only references that resolve to the renamed binding (scope-aware) — a local
241
+ // variable that merely shares the old name is left untouched.
242
+ for (const [oldName, newName] of renames) rewritten = rename(rewritten, oldName, newName)
243
+ // only accept the rewrite if it still parses — never trade a parse error for intent.
244
+ if (renames.length && lang.parses(rewritten)) { merged = rewritten; mergedNames = lang.declaredNames(merged) }
245
+ }
246
+
247
+ // DANGLING REFERENCE (intent): a declaration removed (and NOT explained by a rename)
248
+ // but still referenced is a semantic conflict even though the code parses cleanly.
249
+ // Uses the SCOPE-AWARE reference set when the language provides one (so a local binding
250
+ // that merely shares the deleted name is correctly NOT treated as a reference to it),
251
+ // falling back to the approximate identifier set otherwise.
252
+ removed = [...baseNames].filter((n) => !mergedNames.has(n))
253
+ const refs = lang.referencedFreeNames ? lang.referencedFreeNames(merged)
254
+ : lang.usedIdentifiers ? lang.usedIdentifiers(merged) : null
255
+ if (removed.length && refs) {
256
+ const dangling = removed.filter((n) => refs.has(n))
257
+ if (dangling.length)
258
+ return { status: 'semantic-conflict', text: null, conflicts: dangling.map((n) => 'ref:' + n), reason: 'dangling reference: a declaration was removed/renamed but is still used' }
259
+ }
260
+ // PROVENANCE: attribute each surviving unit to the author whose version we kept. With
261
+ // opts.authors = { a, b, base } the labels are real names; otherwise they're 'a'/'b'/etc.
262
+ return {
263
+ status: 'auto', text: merged, conflicts: [],
264
+ renames: renames.map(([o, n]) => o + '->' + n),
265
+ provenance: attribute(provenance),
266
+ }
267
+ }