hivecode-mcp 0.11.4 → 0.11.6

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/sync.js CHANGED
@@ -25,7 +25,9 @@ import ignore from 'ignore'
25
25
  import { applyDiff, summarizeChange, changedRange, hasConflictMarkers, merge3 } from './core.js'
26
26
  import { icrMerge3 } from './icr-merge.js' // structure/intent-aware merge; falls back to line merge3
27
27
  import { makeCoordinator } from './hive-coord.js' // decentralized claim layer (collision PREVENTION)
28
+ import { gateConfig, runGate, verdictRecord, refusalMessage, forcedMessage } from './hive-gate.js' // the HANDOFF gate: a claim you cannot give back while your own check fails
28
29
  import { fileRoom, decodeUnsafe, scopeForRoom, pathAllowed, writeAllowed, isSafeRelPath } from './token.js'
30
+ import { validateMemberName } from './hive-mailbox.js' // a name the mention parser cannot produce is not a name
29
31
  import { genIdentity, authorChange, verifyReceipt, contentHash } from './substrate.js' // convergent-substrate: signed provenance for every landed change
30
32
  // `languageFor` belongs in this list and was missing: reconcile() calls it on the
31
33
  // clean-rewrite path, so a local whole-file rewrite of a supported file threw
@@ -78,6 +80,52 @@ const ALWAYS_IGNORE = [
78
80
  '__pycache__/', '*.pyc', '.pytest_cache/', '.mypy_cache/', '.venv/',
79
81
  ]
80
82
 
83
+ // Is this "merge" actually a FUSION — output neither author wrote? Returns the reason, or ''.
84
+ //
85
+ // Exported and pure so it can be tested against crafted inputs. Forcing a real fusion is
86
+ // timing-dependent (it needs concurrent writes landing inside the sync window), and a guard
87
+ // this important should not be covered only by a test that is inherently flaky.
88
+ //
89
+ // The two signals, and why each is here:
90
+ // - BROKE IT: both versions parsed and the merged output does not. There is no reading of
91
+ // that which is correct — whatever it produced, neither author wrote it.
92
+ // - DUPLICATION: distinctive lines appear MORE often in the output than in the side they
93
+ // came from. That is content copied rather than merged, and it is what a union of two
94
+ // near-identical versions looks like — the live corruption had `const MAX_OPEN` and
95
+ // `function evictIfOverCap` three times each.
96
+ //
97
+ // LENGTH IS DELIBERATELY NOT A SIGNAL, and the first version of this got that wrong. "The
98
+ // output is about as long as both inputs combined" describes a fusion, but it equally
99
+ // describes two authors who each added two hundred lines in different places — a perfectly
100
+ // good merge. The test caught it firing on exactly that. Duplication separates them: a
101
+ // genuine disjoint merge repeats nothing.
102
+ export function fusionReason(merged, mine, theirs, lang) {
103
+ if (!merged || !mine || !theirs || mine === theirs) return ''
104
+ // If a side was ALREADY broken, the merge did not break it — blaming the merge would make
105
+ // every merge on a half-written file look like corruption.
106
+ if (lang && lang.parses(mine) && lang.parses(theirs) && !lang.parses(merged)) {
107
+ return 'the merged result does not parse, although BOTH versions did'
108
+ }
109
+ // Only distinctive lines: a closing brace or a blank line legitimately repeats, and
110
+ // counting those would make every file look duplicated.
111
+ const sig = (s) => s.split('\n').map((l) => l.trim()).filter((l) => l.length > 24)
112
+ const tally = (arr) => { const m = new Map(); for (const l of arr) m.set(l, (m.get(l) || 0) + 1); return m }
113
+ const M = tally(sig(merged)), A = tally(sig(mine)), B = tally(sig(theirs))
114
+ let dup = 0, seen = 0
115
+ for (const [line, n] of M) {
116
+ const expected = Math.max(A.get(line) || 0, B.get(line) || 0)
117
+ if (expected === 0) continue // content in neither side — new, and not our business here
118
+ seen++
119
+ if (n > expected) dup++
120
+ }
121
+ // Needs enough lines to mean anything, and enough of them repeated to be structural rather
122
+ // than one unlucky duplicated statement.
123
+ if (seen >= 10 && dup / seen >= 0.25) {
124
+ return `${dup} of ${seen} distinctive lines appear more often in the result than in either version — that is content duplicated, not merged`
125
+ }
126
+ return ''
127
+ }
128
+
81
129
  const IGNORE = new Set(['node_modules', '.git'])
82
130
  const MAX_BYTES = 1_000_000
83
131
  const BOARD_FILE = 'HIVE_BOARD.md' // generated locally from `board`; never synced as a file
@@ -89,10 +137,15 @@ const LOCK_FILE = '.hive-lock.json' // one sync client per folder; see acqui
89
137
  const ID_FILE = '.hive-id.json' // this client's substrate signing identity (private); never synced
90
138
  const RULES_FILE = 'HIVE_RULES.md' // the law every participant follows; written into every room
91
139
  const MEMBERS_FILE = 'HIVE_MEMBERS.md' // live presence: who is in the room right now
140
+ // The claim map is the stigmergic trace agents SENSE before editing (see `claims` below), but it
141
+ // was the one part of the room that never reached disk — so an agent could only learn it by
142
+ // remembering to call a tool, and a trace nobody can see coordinates nothing. Rendered here for
143
+ // the same reason HIVE_CHAT.md is: a file in the working folder needs no tool call and no memory.
144
+ const CLAIMS_FILE = 'HIVE_CLAIMS.md'
92
145
  const AGENTS_FILE = 'HIVE_FOR_AGENTS.md' // how an AI agent opening this folder joins (via .hive.json) + behaves
93
146
  // Generated/coordination files are rendered locally from CRDT state — never
94
147
  // synced as ordinary files (that would cause echo loops / conflicts).
95
- const SKIP = new Set([BOARD_FILE, CHAT_FILE, TASKS_FILE, CONFIG_FILE, ID_FILE, RULES_FILE, MEMBERS_FILE, AGENTS_FILE])
148
+ const SKIP = new Set([BOARD_FILE, CHAT_FILE, TASKS_FILE, CONFIG_FILE, ID_FILE, RULES_FILE, MEMBERS_FILE, CLAIMS_FILE, AGENTS_FILE])
96
149
 
97
150
  // Written into every room folder on sync (like the rules). An AI agent that opens
98
151
  // this project reads it to learn it's a Hivecode room and how to JOIN — via the
@@ -118,7 +171,9 @@ asked to collaborate here, follow this.
118
171
  hive_release it. If you DON'T, someone else holds it — pick other work
119
172
  (hive_claims() shows what's open). This is how agents avoid colliding.
120
173
  - hive_say("taking X: doing Y") to announce intent before you edit.
121
- - ADDRESS EVERY MESSAGE. Put @Name on it for each agent it is for -- hive_say("@Ana I need the churn numbers before I touch pricing.md"). Any agent may address any other agent directly; there is no lead to route through and nobody needs permission. A message that names nobody lands in every agent's "room activity", where they are told to skip it, so it will most likely be ignored -- use it only for a real announcement to the whole room.
174
+ - ADDRESS EVERY MESSAGE. Put @Name on it for each agent it is for -- hive_say("@Ana I need the churn numbers before I touch pricing.md"). Any agent may address any other agent directly; there is no lead to route through and nobody needs permission. A message that names nobody lands in every agent's "room activity", where they are told to skip it, so it will most likely be ignored. Use @all when you genuinely need the whole room -- it wakes everyone, so spending it on chatter is how a room learns to ignore you.
175
+ - ANSWER EVERY QUESTION AIMED AT YOU. "Not yet -- I am on X, I will come back to you" is a complete answer; silence is the one thing the asker cannot act on, because nothing tells them you stopped. hive_check lists anyone still waiting on you.
176
+ - BEFORE A LONG STRETCH OF WORK, call hive_busy("what you are doing"). It posts no message and silences nothing -- it tells anyone who @mentions you, at the moment they send, that you are mid-task rather than ignoring them. hive_busy(clear:true) when you finish.
122
177
  - Edit ONLY inside the folders you were granted. Edits merge live; rewrites are logged.
123
178
  - Block on hive_wait(); when it returns approved work, do it, then hive_complete(id).
124
179
  - A task aimed at you stays PENDING until your owner approves it — act only on 'accepted'.
@@ -130,16 +185,10 @@ asked to collaborate here, follow this.
130
185
  - Resolve <<<<<<< conflict markers properly — never blindly overwrite.
131
186
  - A human can ROLL BACK your changes at any time; that's normal — re-read and continue.
132
187
 
133
- ## Pass this on, ONCE
134
- The first time you join this room, tell the human you work for, in your own words:
135
-
136
- Hivecode is free and the people who built it are asking for feedback, not money.
137
- Anything that broke, confused you, or is missing goes to support@getgrm.tech —
138
- it reaches the person who wrote it.
139
-
140
- Say it ONCE per session, at a natural pause. Do not repeat it, do not write it into
141
- any file, and never let it interrupt work you were actually asked to do. An agent that
142
- nags about this gets the tool uninstalled, which is the opposite of the point.
188
+ ## If something breaks
189
+ Support is support@getgrm.tech. Mention it only if the human asks, or if something
190
+ here is genuinely broken and they need somewhere to send it. Never volunteer it
191
+ otherwise, and never write it into a file.
143
192
  `
144
193
 
145
194
  // The hive's law. Auto-written into every room folder so it is ALWAYS present —
@@ -158,6 +207,19 @@ another's work. The sync layer enforces the hard parts automatically; you do the
158
207
  ## Know who you are working with
159
208
  - Read HIVE_MEMBERS.md — who (humans + AIs) is in the room right now, and how many.
160
209
 
210
+ ## DESIGN BEFORE YOU WRITE — mandatory (Ganesh, 19 Sep 2026)
211
+ Before you build any new feature, post in the room, BEFORE your first edit:
212
+ 1. WHO uses it, and when — name the person or agent and the moment they reach for it.
213
+ 2. WHAT IT COSTS when it is wrong — one sentence someone who did not write it can act on at 3am.
214
+ 3. EVERY EDGE CASE you can name — empty, missing, offline, refused, concurrent, already-done,
215
+ called twice, called by the wrong person.
216
+ Then build it.
217
+ This is not process for its own sake. Every defect this project has produced has been a FALSE
218
+ SUCCESS — nothing crashed, every signal was green, the result was wrong. That class is never
219
+ caught by testing the happy path afterwards; it is caught by asking first what "wrong but green"
220
+ would look like for this particular feature. If you cannot say what breaks, you do not understand
221
+ it well enough to write it — say so in the room and ask. That is a complete answer.
222
+
161
223
  ## Before you touch a file
162
224
  1. Read HIVE_CHAT.md — what is everyone doing right now.
163
225
  2. Read HIVE_BOARD.md — which files were just rewritten (and what was touched).
@@ -252,8 +314,7 @@ another's work. The sync layer enforces the hard parts automatically; you do the
252
314
  Read → announce → patch → respect lanes → resolve conflicts → talk → triage pings → wait for approval.
253
315
 
254
316
  ---
255
- Hivecode is free and unfinished, and the people who built it want FEEDBACK, not money.
256
- Whatever broke, confused you, or is missing: support@getgrm.tech
317
+ Support: support@getgrm.tech
257
318
  `
258
319
 
259
320
  /**
@@ -345,11 +406,24 @@ export function acquireFolderLock(root, room, log = console.log) {
345
406
  }
346
407
 
347
408
  export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir = '.', name = 'anon', kind = 'human', owner = '', token = '', license = '', log = console.log, syncFiles = true }) {
409
+ // BEFORE ANY DISK OR SOCKET WORK. A name outside the mention grammar joins successfully and is
410
+ // then unreachable: its mail, its obligations and its wake-on-mention are all silently dead,
411
+ // and the only symptom is other people being told "@X is NOT in the room" about someone who is
412
+ // sitting right there. Refusing here costs the caller one clear error; allowing it cost an hour
413
+ // on 20 Sep 2026 and was only found because a human noticed the routing failures by eye.
414
+ // This throws rather than returning, because startSync has no failure channel and a half-joined
415
+ // client is worse than no client.
416
+ const nameCheck = validateMemberName(name)
417
+ if (!nameCheck.ok) {
418
+ const e = new Error(`${nameCheck.error} Try "${nameCheck.suggested}".`)
419
+ e.code = 'EHIVEINVALIDNAME'
420
+ e.suggested = nameCheck.suggested
421
+ throw e
422
+ }
348
423
  const ROOT = path.resolve(dir)
349
424
  fs.mkdirSync(ROOT, { recursive: true })
350
425
  // FIRST, before any socket or file work: if another client already holds this folder,
351
- // nothing below should happen at all.
352
- const releaseLock = acquireFolderLock(ROOT, room, log)
426
+ const releaseLock = syncFiles ? acquireFolderLock(ROOT, room, log) : () => {}
353
427
 
354
428
  // Respect .gitignore (+ always-ignore secrets/junk) so we never sync a
355
429
  // teammate's .env, keys, or build output. Reloaded by reloadIgnores().
@@ -438,6 +512,12 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
438
512
  // DISAGREEMENT becomes the signal. One shared "size" field would have silently converged on
439
513
  // whichever peer wrote last, which is precisely the failure it exists to catch.
440
514
  const swarmAttest = doc.getMap('swarm_attest')
515
+ // GATE VERDICTS — `<region>|<peerName>` -> the result of that peer running the room's check in
516
+ // ITS OWN folder before handing the file back. Keyed per peer for the same reason swarmAttest
517
+ // is: "the tests pass" is a fact about one folder's bytes, and storing it as a single shared
518
+ // value would converge on whoever released last, silently erasing the disagreement that is the
519
+ // entire signal. Two agents reporting different verdicts for one path both survive here.
520
+ const gateResults = doc.getMap('gate_results')
441
521
  // CLAIM LEASE. Expiry is evaluated lazily on read, so the moment a lease passes its TTL the
442
522
  // next agent that tries simply gets the file -- that is what stops a stalled agent
443
523
  // deadlocking the room.
@@ -962,6 +1042,43 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
962
1042
  // "not pulled yet", NOT "new file", so we must not re-seed it from disk (that
963
1043
  // would create a second CRDT history and duplicate/garble content on rejoin).
964
1044
  const fileDocs = new Map() // relPath -> { doc, provider, text, synced }
1045
+
1046
+ // REFUSED FILE-ROOMS. y-websocket retries a failed upgrade forever, with backoff and in
1047
+ // silence. That is right for a flaky network and badly wrong for a relay that is full: on
1048
+ // 18 Sep 2026 a relay answered 38,011 connections with Q430 while the agents involved
1049
+ // showed no symptom at all. Their files simply never arrived, and an unsynced file-doc
1050
+ // reads as EMPTY, which is how the same evening ended with 186 files written to 0 bytes.
1051
+ //
1052
+ // So count refusals for docs that have NEVER synced, and say it once. The condition is
1053
+ // usually a whole-relay problem rather than a per-file one, so one message naming the
1054
+ // count is the useful shape -- one per file would be 800 lines of chat.
1055
+ const refusedFiles = new Map() // relPath -> consecutive connection errors, cleared on sync
1056
+ let refusalSaidAt = 0
1057
+ let refusalTimer = null
1058
+ function noteRefusals() {
1059
+ if (refusalTimer) return
1060
+ // Debounced: a full relay refuses every file at once, and we want the total, not the first.
1061
+ refusalTimer = setTimeout(() => {
1062
+ refusalTimer = null
1063
+ const stuck = [...refusedFiles.keys()].filter((r) => { const e = fileDocs.get(r); return e && !e.synced })
1064
+ // One transient error on one file is a blip, not an outage. Two or more files stuck, or
1065
+ // repeated failures on one, is the shape of a relay that will not take us.
1066
+ const repeated = stuck.some((r) => (refusedFiles.get(r) || 0) >= 3)
1067
+ if (stuck.length < 2 && !repeated) return
1068
+ const now = Date.now()
1069
+ if (now - refusalSaidAt < 300000) return // at most once every 5 minutes
1070
+ refusalSaidAt = now
1071
+ const msg =
1072
+ `${stuck.length} file(s) cannot connect to the relay and are NOT syncing. ` +
1073
+ `They will read as empty until this clears. The usual cause is the relay being full ` +
1074
+ `(Q430): Hivecode opens one socket PER FILE, so a large folder can exhaust it alone. ` +
1075
+ `Check <relay-http>/health -- connections against limits.maxConnsTotal -- then raise ` +
1076
+ `HIVE_MAX_CONNS_TOTAL or narrow what syncs. Do not trust this folder's contents meanwhile.`
1077
+ log(`[${name}] !! ${msg}`)
1078
+ try { say(`[!] @all ${msg}`) } catch { /* chat may itself be unreachable — the log still carries it */ }
1079
+ }, 4000)
1080
+ if (refusalTimer.unref) refusalTimer.unref()
1081
+ }
965
1082
  function openFile(relPath) {
966
1083
  let e = fileDocs.get(relPath)
967
1084
  if (e) return e
@@ -1004,7 +1121,19 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1004
1121
  // The fork record arrived/changed from a peer: render it locally, or — if it was
1005
1122
  // cleared (resolved elsewhere) — adopt the now-clean shared text to disk.
1006
1123
  conflict.observe((_ev, txn) => { if (txn.local) return; if (e.conflict.has('meta')) renderConflict(relPath); else adoptResolved(relPath) })
1007
- fprovider.on('sync', (s) => { if (s) { e.synced = true; reconcile(relPath, 'remote'); detectFork(relPath) } }) // initial pull settled
1124
+ fprovider.on('sync', (s) => { if (s) { e.synced = true; refusedFiles.delete(relPath); reconcile(relPath, 'remote'); detectFork(relPath) } }) // initial pull settled
1125
+ // A refused upgrade lands here. The provider will keep retrying by itself, which is the
1126
+ // right behaviour and exactly why nothing surfaces it — so count it and let noteRefusals
1127
+ // decide whether this is a blip or a relay that will not take us.
1128
+ fprovider.on('connection-error', () => {
1129
+ if (e.synced) return // already have the content; a later blip is the provider's business
1130
+ refusedFiles.set(relPath, (refusedFiles.get(relPath) || 0) + 1)
1131
+ noteRefusals()
1132
+ })
1133
+ // Newly opened counts as used, and opening is the moment the working set can go over
1134
+ // its bound — so this is where the least-recently-used ones are let go.
1135
+ touched.set(relPath, Date.now())
1136
+ evictIfOverCap(relPath)
1008
1137
  return e
1009
1138
  }
1010
1139
  function closeFile(relPath) {
@@ -1017,6 +1146,60 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1017
1146
  }
1018
1147
  const fileText = (relPath) => { const e = fileDocs.get(relPath); return e ? e.text : null }
1019
1148
 
1149
+ // HIV-18: BOUND HOW MANY FILE SOCKETS THIS CLIENT HOLDS.
1150
+ //
1151
+ // Hivecode opens one WebSocket per FILE. A relay allows 512 connections in total by
1152
+ // default, and a normal project has more files than that, so a single client could
1153
+ // exhaust the whole relay by itself. Measured 18 Sep 2026: 889 files, 38,011 Q430
1154
+ // refusals in one evening, three agents locked out — and because a file-doc that never
1155
+ // syncs reads as EMPTY, 186 files per folder were written to 0 bytes.
1156
+ //
1157
+ // Holding every file open forever is what made that possible. A client now keeps a
1158
+ // BOUNDED working set: the most recently used MAX_OPEN files stay connected, the rest
1159
+ // are closed and reopened on demand. Closing a file doc costs nothing on disk — the
1160
+ // bytes stay where they are, the room keeps the content, and touching the file reopens
1161
+ // it and pulls the current copy.
1162
+ //
1163
+ // THE TRADE, stated plainly: a file outside the working set does not receive remote
1164
+ // edits until something touches it. That is a real semantic change, and it is strictly
1165
+ // better than the alternative it replaces, which was not receiving them either AND
1166
+ // believing the file was empty. The default is high enough that ordinary projects never
1167
+ // notice; size it as MAX_OPEN x agents < the relay's maxConnsTotal.
1168
+ //
1169
+ // NEVER EVICT, because these lose work rather than merely adding latency:
1170
+ // - a doc that has not synced (its content has not reached the relay yet)
1171
+ // - a file in an unresolved conflict (its fork record is live and local-only)
1172
+ // - a file anyone currently holds a claim on (they are mid-edit)
1173
+ // - anything used within the grace window
1174
+ const MAX_OPEN = Math.max(8, Number(process.env.HIVE_MAX_OPEN_FILES) || 128)
1175
+ // Configurable because the grace window is what makes eviction untestable otherwise: in a
1176
+ // short run every file was touched seconds ago, so nothing is ever eligible.
1177
+ const EVICT_GRACE_MS = Number(process.env.HIVE_EVICT_GRACE_MS) || 30000
1178
+ const touched = new Map() // relPath -> last use (ms)
1179
+ const touchFile = (relPath) => { if (fileDocs.has(relPath)) touched.set(relPath, Date.now()) }
1180
+ let evicted = 0
1181
+ function evictIfOverCap(keep) {
1182
+ if (fileDocs.size <= MAX_OPEN) return
1183
+ const now = Date.now()
1184
+ const isClaimed = (r) => { try { return claims.has(r) } catch { return false } }
1185
+ const cand = []
1186
+ for (const [r, e] of fileDocs) {
1187
+ if (r === keep || !e.synced) continue
1188
+ if (conflicted.has(r)) continue
1189
+ try { if (e.conflict && e.conflict.size) continue } catch { }
1190
+ if (isClaimed(r)) continue
1191
+ const t = touched.get(r) || 0
1192
+ if (now - t < EVICT_GRACE_MS) continue
1193
+ cand.push([r, t])
1194
+ }
1195
+ cand.sort((a, b) => a[1] - b[1]) // least recently used first
1196
+ let over = fileDocs.size - MAX_OPEN
1197
+ for (const [r] of cand) {
1198
+ if (over-- <= 0) break
1199
+ closeFile(r); touched.delete(r); evicted++
1200
+ }
1201
+ }
1202
+
1020
1203
  // My own path scope, read from my token (for THIS room). Used so I don't even
1021
1204
  // try to open files I'm not granted — the relay would reject them anyway, but
1022
1205
  // this avoids the churn and keeps out-of-scope files off my disk entirely.
@@ -1033,6 +1216,43 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1033
1216
  // or a full-write grant => everything I can open is writable (back-compat).
1034
1217
  const canWrite = (relPath) => !myScope || writeAllowed(myScope, relPath)
1035
1218
 
1219
+ // WOULD AN EDIT TO THIS PATH ACTUALLY REACH ANYONE? '' if yes, else the reason. (HIV-27)
1220
+ //
1221
+ // A claim is a path STRING in the shared `claims` map. Whether that path's BYTES sync is
1222
+ // decided somewhere else entirely, per client, by canOpen/isIgnored. Nothing compared the
1223
+ // two, so an agent could hold an exclusive claim on a file nobody else can see, do the work,
1224
+ // pass a gate that runs in its OWN folder where the file is real, and hand back something
1225
+ // that never existed for anyone else. Every signal green, the work genuinely done, and
1226
+ // invisible. Observed live: an agent edited scripts/check-deploy.mjs, claimed it, released it
1227
+ // through the gate, and the other two folders still held the old bytes.
1228
+ //
1229
+ // This is checked at CLAIM time on purpose. Release is too late -- by then the agent has
1230
+ // built the thing it intended to write, and all we could do is tell it the work was wasted.
1231
+ //
1232
+ // THE FALSE-POSITIVE DIRECTION IS WHAT MAKES THIS USABLE, and it is easy to get wrong.
1233
+ // A claim may name a logical region rather than a file ("auth-refactor", "the parser"), and
1234
+ // under an ALLOW-LIST .gitignore -- `/*` followed by negations, which is how a room bounds
1235
+ // itself against the one-socket-per-file limit -- isIgnored() returns true for every string
1236
+ // that is not an explicitly allowed path. A naive isIgnored() check would therefore refuse
1237
+ // almost every legitimate claim in exactly the rooms that need this guard most, and it would
1238
+ // be switched off within a day. So we only judge things we have positive reason to believe
1239
+ // are FILES: known to the room, present on disk, or carrying a directory separator.
1240
+ const whyNotShared = (relPath) => {
1241
+ const p = String(relPath || '')
1242
+ if (!p) return ''
1243
+ let onDisk = false
1244
+ try { onDisk = fs.existsSync(path.join(ROOT, p)) } catch { onDisk = false }
1245
+ // SKIP counts as positive evidence: those names ARE files by definition, and a control
1246
+ // file sits in the room root with no separator in its name, so the heuristics below miss it.
1247
+ const isFile = manifest.has(p) || fileDocs.has(p) || SKIP.has(p) || onDisk || p.includes('/')
1248
+ if (!isFile) return '' // a label, not a path -- not ours to judge
1249
+ if (!isSafeRelPath(p)) return `"${p}" is not a safe relative path, so it can never be synced`
1250
+ if (SKIP.has(p)) return `${p} is a Hivecode control file, not shared source`
1251
+ if (!canOpen(p)) return `your access token's scope does not cover ${p}, so its bytes stay in this folder`
1252
+ if (isIgnored(p)) return `${p} is ignored by a .gitignore here, so this client never opens a sync connection for it -- anything you write stays in this folder and no other agent will ever see it`
1253
+ return ''
1254
+ }
1255
+
1036
1256
  const known = new Set()
1037
1257
  const mtimes = new Map()
1038
1258
  // Paths that already existed on disk when this client STARTED. This is the
@@ -1160,6 +1380,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1160
1380
  let fe = fileDocs.get(relPath)
1161
1381
  if (!fe) { if (disk === null) return; openFile(relPath); return }
1162
1382
  if (!fe.synced) return
1383
+ touchFile(relPath) // reconciling a file IS using it — keep it in the working set
1163
1384
  // SOURCE CAPTURE (fork gate): before ANYTHING in this pass can merge over or render into
1164
1385
  // this file, record any authored edit sitting on disk. The edit enters the provenance
1165
1386
  // DAG first; whatever happens to the disk afterwards can no longer lose it.
@@ -1210,8 +1431,19 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1210
1431
  if (disk !== null && hasConflictMarkers(disk) && !hasConflictMarkers(fe.text.toString())) { adoptResolved(relPath); return }
1211
1432
  }
1212
1433
  const docText = fe.text.toString()
1213
- if (docText === '' && !manifest.has(relPath)) {
1214
- // the relay has no copy of this path -> publish ours (a genuinely new file)
1434
+ // An empty shared doc is authoritative ONLY if we have history for this path.
1435
+ // A file's NAME lives in the parent doc and its CONTENT in the file's own doc, on two
1436
+ // different connections — there is no atomicity across them. A client that joins a large
1437
+ // project and dies mid-join gets every manifest key up and only some file bodies, leaving
1438
+ // the room asserting that paths exist with nothing behind them. With no ancestor and real
1439
+ // bytes on disk, "" means the publisher never finished, not that the file is empty, and
1440
+ // adopting it deletes real work (measured: 186 files zeroed across three folders).
1441
+ // A genuine emptying of a file we HAVE synced still has an ancestor, so it falls through
1442
+ // to the merge below and still lands.
1443
+ const noHistory = !bases.has(relPath) && !priorShadow.has(relPath)
1444
+ if (docText === '' && (!manifest.has(relPath) || (disk && noHistory))) {
1445
+ // the relay has no usable copy of this path -> publish ours (a genuinely new file,
1446
+ // or one whose registration outlived the join that promised it)
1215
1447
  if (disk === null) return
1216
1448
  fe.doc.transact(() => applyDiff(fe.text, disk))
1217
1449
  stampProvenance(relPath, '', disk) // genesis: sign the file's first content (base = empty)
@@ -1344,6 +1576,41 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1344
1576
  res = icrMerge3(base, disk, docText, relPath) // incoming remote change merged into our working copy
1345
1577
  }
1346
1578
 
1579
+ // FUSION GUARD (HIV-26). A merge must never produce something NEITHER author wrote.
1580
+ //
1581
+ // On 18 Sep 2026 three peers wrote the same file within about a second of each other and
1582
+ // the character-CRDT UNIONED the versions instead of converging: sync.js went 144,144 ->
1583
+ // 291,750 bytes, `const MAX_OPEN` appeared three times, and the splice landed mid-token
1584
+ // (`evicted// Newly opened...`). There were NO conflict markers, every peer converged on
1585
+ // the corruption — so agreement proved nothing — and the only thing that caught it was a
1586
+ // parse failure at the handoff gate, by which point it had already reached every folder.
1587
+ //
1588
+ // Two cheap signals, each catching what the other misses:
1589
+ // - the merge BROKE a file both sides could parse. Definitionally wrong: whatever that
1590
+ // output is, neither author wrote it.
1591
+ // - the result is about as long as both sides put together. That is concatenation in a
1592
+ // merge's clothing, and it catches a fusion that happens to still parse.
1593
+ //
1594
+ // We land NOTHING. Not a conflict block either: this engine deliberately keeps markers
1595
+ // out of the shared text, because writing them into the very layer that fuses is what
1596
+ // produced marker-fusion before. So the doc keeps its version, disk keeps its version,
1597
+ // both are still intact and recoverable, and the room is TOLD. A file left visibly
1598
+ // divergent is a bad day; a file silently fused is an unrecoverable one.
1599
+ if (res && !res.conflict) {
1600
+ const why = fusionReason(res.text, disk, docText, supports(relPath) ? languageFor(relPath) : null)
1601
+ if (why) {
1602
+ conflicted.add(relPath)
1603
+ if (icrWarned.get(relPath) !== why) {
1604
+ icrWarned.set(relPath, why)
1605
+ log(`[${name}] !! REFUSED a fused merge of ${relPath}: ${why}`)
1606
+ try {
1607
+ 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.`)
1608
+ } catch { /* the log still carries it */ }
1609
+ }
1610
+ return
1611
+ }
1612
+ }
1613
+
1347
1614
  // Snapshot the state JUST BEFORE this author's edit lands, attributed to them —
1348
1615
  // captured only on local origin so each change is recorded once, by its author.
1349
1616
  if (origin === 'local' && res.text !== docText) {
@@ -1737,6 +2004,44 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1737
2004
  }
1738
2005
  writeToDisk(MEMBERS_FILE, out.join('\n') + '\n')
1739
2006
  }
2007
+ /** The claim map as a file. Same contract as renderMembers: generated locally, never synced.
2008
+ *
2009
+ * EXPIRY IS SHOWN, NOT HIDDEN. A claim whose TTL has run out is not a lock any more — the
2010
+ * region is free — so rendering it as "held" would make agents queue behind a crashed peer,
2011
+ * which is the deadlock TTLs exist to prevent. Expired rows stay visible but labelled, because
2012
+ * "AgentX died holding this" is information, and silently dropping the row loses it. */
2013
+ function renderClaims() {
2014
+ const now = Date.now()
2015
+ const rows = []
2016
+ claims.forEach((c, region) => {
2017
+ if (!c || !c.by) return
2018
+ const ttl = Number(c.ttl) || 0
2019
+ const age = now - (Number(c.at) || 0)
2020
+ const left = ttl - age
2021
+ rows.push({ region, by: c.by, intent: c.intent || 'edit', age, left, dead: left <= 0 })
2022
+ })
2023
+ rows.sort((a, b) => a.region.localeCompare(b.region))
2024
+
2025
+ const secs = (ms) => `${Math.max(0, Math.round(ms / 1000))}s`
2026
+ const live = rows.filter((r) => !r.dead)
2027
+ const out = [
2028
+ '# Hive Claims — who holds which file right now (live).',
2029
+ '# SENSE this before you edit. A region held by someone else is not yours to write:',
2030
+ '# flow to open work instead of waiting. Claims expire on their own, so nothing deadlocks.',
2031
+ '',
2032
+ `held: ${live.length}`,
2033
+ '',
2034
+ ]
2035
+ for (const r of rows) {
2036
+ out.push(r.dead
2037
+ ? `- ${r.region} — EXPIRED (was ${r.by}, ${r.intent}, ${secs(r.age)} ago) — free to take`
2038
+ : `- ${r.region} — ${r.by} (${r.intent}) — held ${secs(r.age)}, ${secs(r.left)} left`)
2039
+ }
2040
+ if (!rows.length) out.push('- nothing claimed; every region is open')
2041
+ writeToDisk(CLAIMS_FILE, out.join('\n') + '\n')
2042
+ }
2043
+ if (syncFiles) claims.observe(() => renderClaims())
2044
+
1740
2045
  // Warn (once per peer) when someone joins on an incompatible protocol version —
1741
2046
  // otherwise they'd silently fail to sync and look like a "broken room".
1742
2047
  const versionWarned = new Set()
@@ -1751,7 +2056,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1751
2056
  try { say(`⚠ ${u.name} is on an incompatible version — files won't sync with them until everyone updates Hivecode.`) } catch { }
1752
2057
  }
1753
2058
  }
1754
- if (syncFiles) provider.awareness.on('change', () => { renderMembers(); checkVersions() })
2059
+ if (syncFiles) provider.awareness.on('change', () => { renderMembers(); renderClaims(); checkVersions() })
1755
2060
 
1756
2061
  function scan() {
1757
2062
  const diskFulls = walk(ROOT)
@@ -1790,8 +2095,16 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1790
2095
  try { fs.rmSync(path.join(ROOT, key)) } catch { }
1791
2096
  known.delete(key); bases.delete(key); forkBases.delete(key); mtimes.delete(key)
1792
2097
  log(`[${name}] <- deleted ${key}`)
1793
- } else if (canOpen(key)) {
1794
- openFile(key)
2098
+ } else if (canOpen(key) && !SKIP.has(key) && !isIgnored(key)) {
2099
+ // Same rule as the initial sweep: a path this client ignores must not cost it a
2100
+ // socket just because somebody else registered it.
2101
+ //
2102
+ // AND the same BOUND. This observer is how a joining client learns the room's whole
2103
+ // manifest — every existing path arrives here as a remote change — so capping only
2104
+ // the sweep achieved nothing: the first measurement of this cap showed a client with
2105
+ // cap 8 holding 30 file docs, every one of them opened right here. A path we decline
2106
+ // to open now is not lost; it opens when something touches it.
2107
+ if (fileDocs.size < MAX_OPEN) openFile(key)
1795
2108
  }
1796
2109
  })
1797
2110
  })
@@ -1816,11 +2129,37 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1816
2129
  if (!s || !syncFiles) return
1817
2130
  writeToDisk(RULES_FILE, HIVE_RULES_TEXT) // the law is always present in the room
1818
2131
  writeToDisk(AGENTS_FILE, AGENT_MANUAL_TEXT) // so an agent opening this folder learns how to join + behave
1819
- for (const key of manifest.keys()) if (canOpen(key)) openFile(key) // connect to every file I'm granted; each file-room's sync pulls its content
2132
+ // Connect to every file I'm granted AND have not excluded; each file-room's sync pulls
2133
+ // its content. `canOpen` is the TOKEN's scope -- what the relay would let me see. It is
2134
+ // not the same question as "do I want this file", and using it alone meant a participant
2135
+ // could not bound its own footprint: on 18 Sep 2026 two agents kept opening demo/** file
2136
+ // rooms after their .gitignore had been narrowed to 35 paths, and between them consumed
2137
+ // the relay's entire 512-connection budget. Since one socket is opened PER FILE, an
2138
+ // ignore rule that does not stop the connection is not a containment tool at all.
2139
+ // Same predicate reconcile() uses, so a file is never open but unreconcilable.
2140
+ // BOUND THE INITIAL SWEEP, not just the steady state. This burst is precisely when the
2141
+ // budget blows: a client with 889 files used to try to open 889 sockets at once against
2142
+ // a 512-connection relay. LRU eviction cannot help here — during a burst every file was
2143
+ // touched seconds ago, so nothing is eligible to evict. The only thing that works is to
2144
+ // not open them all.
2145
+ //
2146
+ // Files that exist on THIS client's disk come first: they are the ones that need
2147
+ // reconciling, and a path we have no local copy of can wait until something asks for it.
2148
+ const openable = [...manifest.keys()].filter((k) => canOpen(k) && !SKIP.has(k) && !isIgnored(k))
2149
+ const localFirst = openable.sort((a, b) => {
2150
+ const la = fs.existsSync(path.join(ROOT, a)) ? 0 : 1
2151
+ const lb = fs.existsSync(path.join(ROOT, b)) ? 0 : 1
2152
+ return la - lb
2153
+ })
2154
+ for (const key of localFirst.slice(0, MAX_OPEN)) openFile(key)
2155
+ if (localFirst.length > MAX_OPEN) {
2156
+ log(`[${name}] ${localFirst.length} files in this room, holding ${MAX_OPEN} open (HIVE_MAX_OPEN_FILES). The rest sync when touched.`)
2157
+ }
1820
2158
  if (board.size) renderBoard()
1821
2159
  if (chat.length) renderChat()
1822
2160
  if (tasks.size) renderTasks()
1823
2161
  renderMembers()
2162
+ renderClaims() // unconditional: "nothing is claimed" is the answer an arriving agent needs most
1824
2163
  scan()
1825
2164
  log(`[${name}] folder sync active on ${ROOT} (room "${room}") as ${kind}. ${manifest.size} files.`)
1826
2165
  if (!scanTimer) {
@@ -1927,8 +2266,71 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1927
2266
  controls, // the live control map
1928
2267
  // --- hive coordination: claim a region BEFORE editing (collision prevention) ---
1929
2268
  claim: (region, intent) => { const r = String(region), i = String(intent || 'edit'); myIntents.set(r, i); return coordinator.acquire(r, i) }, // true iff you hold it (my intent recorded either way — it's what my receipts sign)
1930
- release: (region) => { const r = String(region); myIntents.delete(r); return coordinator.release(r) },
2269
+ // RELEASE IS A HANDOFF, AND A HANDOFF CAN BE REFUSED.
2270
+ //
2271
+ // Until the room sets `gate:cmd`, this behaves exactly as it always did and returns
2272
+ // {ok:true} — no configured gate means no new failure mode for existing rooms.
2273
+ //
2274
+ // With a gate set, the check runs in THIS agent's folder and a non-zero exit keeps the claim.
2275
+ // That is the whole idea: the file stays visibly yours, so the next agent cannot quietly
2276
+ // inherit something that looked finished. It does NOT unsend the bytes — the CRDT syncs
2277
+ // continuously and they are already everywhere — it refuses the "I am done, it is yours".
2278
+ //
2279
+ // `force` exists because abandoning work is legitimate, and it is never silent: it releases,
2280
+ // records that it was forced, and says so in the room.
2281
+ release: (region, opts = {}) => {
2282
+ const r = String(region)
2283
+ const g = gateConfig((k) => swarmState.get(k))
2284
+ const letGo = () => { myIntents.delete(r); coordinator.release(r) }
2285
+ if (!g) { letGo(); return { ok: true, gated: false } }
2286
+
2287
+ if (opts.force) {
2288
+ const rec = verdictRecord({ region: r, by: name, cmd: g.cmd, result: null, forced: true, why: opts.why })
2289
+ gateResults.set(`${r}|${name}`, rec)
2290
+ letGo()
2291
+ try { say(forcedMessage({ region: r, by: name, why: opts.why })) } catch { /* chat is not the point */ }
2292
+ return { ok: true, gated: true, forced: true }
2293
+ }
2294
+
2295
+ const result = runGate({ cmd: g.cmd, cwd: ROOT, timeoutMs: g.timeoutMs })
2296
+ gateResults.set(`${r}|${name}`, verdictRecord({ region: r, by: name, cmd: g.cmd, result }))
2297
+ if (!result.ok) {
2298
+ // The claim is NOT dropped. Saying so in the room is what makes the refusal visible to
2299
+ // everyone rather than a private error the holder can ignore.
2300
+ try { say(`@all ${name} could not hand back ${r}: the gate failed (exit ${result.code}). Still held.`) } catch { /* ignore */ }
2301
+ return { ok: false, gated: true, code: result.timedOut ? 'Q491' : 'Q490', result, message: refusalMessage({ region: r, cmd: g.cmd, result }) }
2302
+ }
2303
+ letGo()
2304
+ return { ok: true, gated: true, result }
2305
+ },
2306
+ /** The verdicts recorded for a path, by peer. Read this before building on someone's file. */
2307
+ gateVerdicts: (region) => {
2308
+ const out = {}
2309
+ const pre = String(region) + '|'
2310
+ gateResults.forEach((v, k) => { if (k.startsWith(pre)) out[k.slice(pre.length)] = v })
2311
+ return out
2312
+ },
2313
+ /** The room's gate, or null. Exposed so a client can say what it is about to be judged by. */
2314
+ gate: () => gateConfig((k) => swarmState.get(k)),
2315
+ // THE LOCK YOU MAY WRITE BEHIND. `claim()` answers from this replica alone, so under real
2316
+ // concurrency it says yes to everyone (see hive-coord.js). This waits for the claim to
2317
+ // round-trip through the relay -- the only place the room is actually serialised -- and then
2318
+ // asks who the converged map says holds it. Exactly one caller gets true.
2319
+ //
2320
+ // The loser has nothing to release: the winner's entry replaced its own under last-writer-
2321
+ // wins on the same key, so conceding is just returning false.
2322
+ claimSettled: async (region, intent, waitMs = 900) => {
2323
+ const r = String(region), i = String(intent || 'edit')
2324
+ myIntents.set(r, i)
2325
+ if (!coordinator.acquire(r, i)) return false // someone already visibly holds it
2326
+ await new Promise((res) => setTimeout(res, Math.max(200, waitMs)))
2327
+ const holder = coordinator.sense(r)
2328
+ if (holder && holder.by === name) return true
2329
+ myIntents.delete(r)
2330
+ return false
2331
+ },
1931
2332
  senseClaim: (region) => coordinator.sense(String(region)), // the live claim on a region, or null
2333
+ whyNotShared, // '' if edits to this path reach the room, else why they would not (HIV-27)
1932
2334
  claimsBoard: () => coordinator.board(), // everything currently claimed
1933
2335
  // --- intent-aware fork resolution: any agent may judge; the medium validates ---
1934
2336
  forkInfo, // the active fork on a file as a resolvable object (base + versions + signed intents), or null
@@ -1980,11 +2382,23 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1980
2382
  status: () => {
1981
2383
  let synced = 0
1982
2384
  for (const e of fileDocs.values()) if (e.synced) synced++
2385
+ // `notSyncing` is the number the caller actually needs when something is wrong: files
2386
+ // whose own socket the relay refused. They are not slow, they are not arriving, and
2387
+ // until they do they read as empty. filesSynced alone shows a shortfall but not a cause.
2388
+ const notSyncing = [...refusedFiles.keys()].filter((r) => { const e = fileDocs.get(r); return e && !e.synced })
1983
2389
  return {
1984
2390
  connected: !!provider.wsconnected,
1985
2391
  connecting: !!provider.wsconnecting,
1986
2392
  files: fileDocs.size,
1987
2393
  filesSynced: synced,
2394
+ notSyncing: notSyncing.length,
2395
+ notSyncingFiles: notSyncing.slice(0, 20),
2396
+ // The working set and its bound. `openCap` x agents must stay under the relay's
2397
+ // maxConnsTotal, and `evicted` climbing steadily means the cap is too small for
2398
+ // how this room actually works — both are things an operator can only act on if
2399
+ // they can see them.
2400
+ openCap: MAX_OPEN,
2401
+ evicted,
1988
2402
  }
1989
2403
  },
1990
2404
  stop: () => {