hivecode-mcp 0.8.0 → 0.11.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/package.json CHANGED
@@ -1,70 +1,97 @@
1
- {
2
- "name": "hivecode-mcp",
3
- "version": "0.8.0",
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
- "type": "module",
6
- "license": "MIT",
7
- "homepage": "https://github.com/Ganesh-Kute/HiveCode",
8
- "bugs": {
9
- "url": "https://github.com/Ganesh-Kute/HiveCode/issues"
10
- },
11
- "repository": {
12
- "type": "git",
13
- "url": "https://github.com/Ganesh-Kute/HiveCode"
14
- },
15
- "keywords": [
16
- "mcp",
17
- "model-context-protocol",
18
- "ai-agents",
19
- "multi-agent",
20
- "claude",
21
- "collaboration",
22
- "realtime",
23
- "yjs"
24
- ],
25
- "bin": {
26
- "hivecode-mcp": "./hive-mcp.js"
27
- },
28
- "files": [
29
- "hive-mcp.js",
30
- "sync.js",
31
- "core.js",
32
- "token.js",
33
- "icr.js",
34
- "lang-js.js",
35
- "lang-brace.js",
36
- "lang-python.js",
37
- "icr-merge.js",
38
- "hive-coord.js",
39
- "substrate.js",
40
- "HIVE_RULES.md",
41
- "MCP.md",
42
- "README.md",
43
- "LICENSE"
44
- ],
45
- "publishConfig": {
46
- "access": "public"
47
- },
48
- "scripts": {
49
- "start": "node server.js",
50
- "relay": "node server.js",
51
- "test": "node test.js",
52
- "test:substrate": "node substrate-test.js",
53
- "test:substrate:live": "node substrate-live-test.js",
54
- "test:substrate:enforce": "node substrate-enforce-test.js",
55
- "test:substrate:authority": "node substrate-authority-test.js",
56
- "test:substrate:all": "node substrate-test.js && node substrate-live-test.js && node substrate-enforce-test.js && node substrate-authority-test.js"
57
- },
58
- "engines": {
59
- "node": ">=18"
60
- },
61
- "dependencies": {
62
- "@anthropic-ai/sdk": "^0.104.2",
63
- "@modelcontextprotocol/sdk": "^1.29.0",
64
- "acorn": "^8.17.0",
65
- "ignore": "^7.0.5",
66
- "ws": "^8.18.0",
67
- "y-websocket": "^2.0.4",
68
- "yjs": "^13.6.18"
69
- }
70
- }
1
+ {
2
+ "name": "hivecode-mcp",
3
+ "version": "0.11.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/Ganesh-Kute/HiveCode",
8
+ "bugs": {
9
+ "url": "https://github.com/Ganesh-Kute/HiveCode/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/Ganesh-Kute/HiveCode.git"
14
+ },
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "ai-agents",
19
+ "multi-agent",
20
+ "claude",
21
+ "collaboration",
22
+ "realtime",
23
+ "yjs"
24
+ ],
25
+ "bin": {
26
+ "hivecode-mcp": "hive-mcp.js"
27
+ },
28
+ "files": [
29
+ "hive-mcp.js",
30
+ "sync.js",
31
+ "core.js",
32
+ "token.js",
33
+ "icr.js",
34
+ "lang-js.js",
35
+ "lang-brace.js",
36
+ "lang-python.js",
37
+ "lang-ruby.js",
38
+ "lang-json.js",
39
+ "lang-yaml.js",
40
+ "lang-toml.js",
41
+ "icr-merge.js",
42
+ "hive-coord.js",
43
+ "substrate.js",
44
+ "HIVE_RULES.md",
45
+ "MCP.md",
46
+ "README.md",
47
+ "LICENSE"
48
+ ],
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "scripts": {
53
+ "prepare": "node scripts/install-hooks.mjs || exit 0",
54
+ "start": "node server.js",
55
+ "relay": "node server.js",
56
+ "sync:engine": "node sync-engine.mjs",
57
+ "check:engine": "node sync-engine.mjs --check",
58
+ "pretest": "node scripts/sync-engine.mjs --check",
59
+ "test": "node tests/test.js",
60
+ "test:billing": "node tests/billing-test.mjs",
61
+ "test:license": "node tests/hive-license-test.js",
62
+ "test:money": "node tests/billing-test.mjs && node tests/hive-license-test.js && node tests/recover-test.mjs",
63
+ "check:web": "tsc --noEmit",
64
+ "build:web": "esbuild web/src/main.ts --bundle --minify --format=iife --target=es2019 --outfile=public/app.js",
65
+ "test:substrate": "node substrate-test.js",
66
+ "test:substrate:live": "node substrate-live-test.js",
67
+ "test:substrate:enforce": "node substrate-enforce-test.js",
68
+ "test:substrate:authority": "node substrate-authority-test.js",
69
+ "test:substrate:all": "node substrate-test.js && node substrate-live-test.js && node substrate-enforce-test.js && node substrate-authority-test.js",
70
+ "test:recover": "node tests/recover-test.mjs",
71
+ "test:mailbox": "node tests/mailbox-test.mjs && node tests/mcp-wait-test.mjs"
72
+ },
73
+ "engines": {
74
+ "node": ">=18"
75
+ },
76
+ "dependencies": {
77
+ "@anthropic-ai/sdk": "^0.104.2",
78
+ "@modelcontextprotocol/sdk": "^1.30.0",
79
+ "acorn": "^8.17.0",
80
+ "ignore": "^7.0.5",
81
+ "ws": "^8.18.0",
82
+ "y-websocket": "^2.0.4",
83
+ "yjs": "^13.6.18",
84
+ "zod": "^4.4.3"
85
+ },
86
+ "devDependencies": {
87
+ "@types/node": "^26.1.2",
88
+ "esbuild": "^0.28.1",
89
+ "typescript": "^7.0.2"
90
+ },
91
+ "main": "core.js",
92
+ "directories": {
93
+ "doc": "docs",
94
+ "test": "tests"
95
+ },
96
+ "author": "Ganesh Shivlal Kute <support@getgrm.tech>"
97
+ }
package/sync.js CHANGED
@@ -26,6 +26,7 @@ import { icrMerge3 } from './icr-merge.js' // structure/intent-aware merge; fall
26
26
  import { makeCoordinator } from './hive-coord.js' // decentralized claim layer (collision PREVENTION)
27
27
  import { fileRoom, decodeUnsafe, scopeForRoom, pathAllowed, writeAllowed, isSafeRelPath } from './token.js'
28
28
  import { genIdentity, authorChange, verifyReceipt, contentHash } from './substrate.js' // convergent-substrate: signed provenance for every landed change
29
+ import { structuralMerge, supports } from './icr.js' // validates a judged fork resolution before it may land (parse + dangling refs)
29
30
 
30
31
  // Wire-format/protocol version. Bump when a change makes clients incompatible
31
32
  // (e.g. the v2 per-file-subdoc model). Broadcast via awareness so peers can warn
@@ -38,6 +39,10 @@ const ALWAYS_IGNORE = [
38
39
  '.git/', 'node_modules/',
39
40
  '.env', '.env.*', '*.pem', '*.key', '*.pfx', '*.p12', 'id_rsa', 'id_ed25519', '*.keystore',
40
41
  '.hive-id.json', // this client's substrate signing identity (holds a PRIVATE key) — never sync it
42
+ // This client's own session + chat read cursor. Syncing it would push one agent's identity
43
+ // and read position into every other agent's folder, and a shared cursor means one agent
44
+ // consuming another's unread mail.
45
+ '.hive-mcp-session.json',
41
46
  '*.log', '.DS_Store', 'Thumbs.db', '*.vsix',
42
47
  ]
43
48
 
@@ -216,7 +221,25 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
216
221
  const claims = doc.getMap('claims')
217
222
  const swarmState = doc.getMap('swarm_state')
218
223
  const swarmSchema = doc.getMap('swarm_schema')
224
+ // ATTESTATION LEDGER — path -> { <peerName>: { bytes, lines, at } }.
225
+ //
226
+ // The one thing a four-agent build could not do without a human relaying numbers by hand: tell
227
+ // whether two folders actually hold the same file. Measured on the JARVIS run (2026-07-29):
228
+ // engine/brain/llm.js was 178 lines and parsing in one folder and 22,312 lines with 73 conflict
229
+ // markers in two others, and the agent with the clean copy reported a green suite in good faith
230
+ // for hours. Nothing in the product could have told any of them.
231
+ //
232
+ // Why bytes per PEER rather than a single value: a CRDT map keyed by peer name MERGES instead of
233
+ // last-writer-wins, so two peers attesting different byte counts both survive and the
234
+ // DISAGREEMENT becomes the signal. One shared "size" field would have silently converged on
235
+ // whichever peer wrote last, which is precisely the failure it exists to catch.
236
+ const swarmAttest = doc.getMap('swarm_attest')
219
237
  const coordinator = makeCoordinator(claims, name, { ttl: 300000 }) // 5-min claim TTL
238
+ // MY declared intent per region — recorded on every claim ATTEMPT, kept locally.
239
+ // The provenance stamp reads this (not the shared claim map) so an agent always signs
240
+ // ITS OWN intent: under a true concurrent collision the shared claim converges to ONE
241
+ // winner, and reading it would misattribute the loser's receipt with the winner's WHY.
242
+ const myIntents = new Map()
220
243
 
221
244
  // --- convergent substrate: signed provenance (opt-in via HIVE_PROVENANCE) ------
222
245
  // When on, this client loads (or mints) a persistent Ed25519 identity and SIGNS
@@ -260,7 +283,8 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
260
283
  if (fe.ledger.toArray().some((r) => r && r.author === identity.id && r.parent === pHash && r.contentHash === cHash0)) return
261
284
  try {
262
285
  const c = coordinator.sense(relPath)
263
- const ch = authorChange({ identity, filename: relPath, base: base == null ? '' : base, text: next, intent: (c && c.intent) || 'edit', at: Date.now() })
286
+ const intent = myIntents.get(relPath) || (c && c.by === name && c.intent) || 'edit'
287
+ const ch = authorChange({ identity, filename: relPath, base: base == null ? '' : base, text: next, intent, at: Date.now() })
264
288
  // `name` is display-only (NOT signed) — the verified identity is `author` (the key
265
289
  // fingerprint). A UI shows both: the friendly name, and the cryptographic author it
266
290
  // must match. Signature covers everything else, so tampering `name` can't fake trust.
@@ -282,7 +306,8 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
282
306
  if (cur && cur.hash === contentHash(landed)) return // head already attests this content
283
307
  const c = coordinator.sense(relPath)
284
308
  const at = Date.now()
285
- const ch = authorChange({ identity, filename: relPath, base: base == null ? '' : base, text: landed, intent: (c && c.intent) || 'edit', at })
309
+ const intent = myIntents.get(relPath) || (c && c.by === name && c.intent) || 'edit'
310
+ const ch = authorChange({ identity, filename: relPath, base: base == null ? '' : base, text: landed, intent, at })
286
311
  fe.doc.transact(() => {
287
312
  fe.head.set('cur', { text: landed, hash: ch.prov.contentHash, at, by: name, receipt: { ...ch.prov, name } })
288
313
  storeVersion(fe, landed)
@@ -445,7 +470,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
445
470
  conflicted.add(relPath)
446
471
  const who = rec.versions.map((v) => v.name).join(', ')
447
472
  log(`[${name}] ⚠ SILENT FORK in ${relPath} — ${who} changed the same region at once; provenance caught it. Surfaced ALL ${rec.versions.length} versions with <<<<<<< markers (rendered locally, not into the shared text).`)
448
- try { say(`⚠ SILENT FORK in ${relPath}: ${who} changed the same region concurrently. Surfaced ALL ${rec.versions.length} versions with <<<<<<< markers. Resolve before continuing.`) } catch { }
473
+ // Carry each side's SIGNED intent into the announcement: the WHY is what lets any
474
+ // agent in the room reconcile the fork (via hive_resolve) instead of picking a winner.
475
+ const why = rec.versions.map((v) => `${v.name}: "${versionIntent(fe, rec, v.hash) || 'no stated intent'}"`).join(' | ')
476
+ try { say(`⚠ SILENT FORK in ${relPath}: ${who} changed the same region concurrently. Signed intents — ${why}. Surfaced ALL ${rec.versions.length} versions with <<<<<<< markers. ANY agent may reconcile BOTH intents: call hive_resolve with the full resolved file — it is validated (parses, no dangling refs) before it lands.`) } catch { }
449
477
  }
450
478
  }
451
479
  // A resolution landed elsewhere: the shared text is clean again. Adopt it to disk.
@@ -458,6 +486,112 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
458
486
  bases.set(relPath, dt); forkBases.set(relPath, dt)
459
487
  conflicted.delete(relPath)
460
488
  }
489
+ // The VERIFIED signed intent behind one fork version: the receipt that produced this
490
+ // content from the fork's parent, signature-checked — so the WHY a judge reconciles is
491
+ // cryptographically the author's own declaration, not something another peer typed.
492
+ function versionIntent(fe, rec, hash) {
493
+ const r = fe.ledger.toArray().find((r) => r && r.contentHash === hash && r.parent === rec.parent && verifyReceipt(r).ok)
494
+ return (r && r.intent) || ''
495
+ }
496
+ // The active fork as a machine-RESOLVABLE object: the common base plus every divergent
497
+ // version with its author and verified intent. This is what an agent judge reads before
498
+ // proposing a reconciliation — the conflict is data, not just markers on a disk.
499
+ function forkInfo(relPath) {
500
+ const fe = fileDocs.get(relPath); if (!fe) return null
501
+ const rec = forkRecord(fe); if (!rec) return null
502
+ return {
503
+ file: relPath,
504
+ base: rec.base,
505
+ versions: rec.versions.map((v) => ({ name: v.name, author: v.author, intent: versionIntent(fe, rec, v.hash), text: fe.versions.get(v.hash) })),
506
+ }
507
+ }
508
+ // INTENT-AWARE FORK RESOLUTION — the judged path out of a surfaced silent fork.
509
+ // Any participant (typically an AI agent that has read both sides' signed intents via
510
+ // forkInfo / the chat announcement) proposes the FULL reconciled file; the MEDIUM
511
+ // validates it before it may land:
512
+ // - no conflict markers left behind,
513
+ // - (supported language) judged as a one-sided change from the fork's base through
514
+ // ICR structuralMerge: it must PARSE and must not leave a DANGLING reference —
515
+ // the same guarantee the icr-merge library's resolveMerge gives a judge, so broken
516
+ // judge output physically cannot ship. (A rename in the proposal even gets its
517
+ // stale call sites rewritten, same as any merge.)
518
+ // A landed resolution takes the exact convergent path a human resolution takes: it is
519
+ // signed by the resolver, folds the fork versions, clears the record on every peer,
520
+ // and is announced. A refused proposal changes NOTHING — the fork stays surfaced.
521
+ function resolveFork(relPath, proposal) {
522
+ const fe = fileDocs.get(relPath)
523
+ if (!fe || !fe.synced || !fe.conflict) return { ok: false, reason: `no active fork on ${relPath}` }
524
+ const rec = forkRecord(fe)
525
+ if (!rec) return { ok: false, reason: `no active fork on ${relPath}` }
526
+ if (!canWrite(relPath)) return { ok: false, reason: `${relPath} is read-only for you` }
527
+ if (typeof proposal !== 'string' || !proposal.trim()) return { ok: false, reason: 'empty resolution' }
528
+ if (hasConflictMarkers(proposal)) return { ok: false, reason: 'resolution still contains <<<<<<< conflict markers — send the final clean file' }
529
+ let landed = proposal
530
+ if (supports(relPath) && rec.base != null) {
531
+ const v = structuralMerge(rec.base, proposal, rec.base, { filename: relPath })
532
+ if (v.status !== 'auto') return { ok: false, reason: `resolution refused by ICR validation: ${v.reason || (v.conflicts || []).join(', ') || v.status}` }
533
+ landed = v.text
534
+ }
535
+ // INTENT COVERAGE (informational, deterministic): did each side's TOKEN-level
536
+ // changes survive into the landed text? Token granularity is what makes a genuine
537
+ // reconciliation count for both sides (a merged ternary contains both sides' new
538
+ // tokens) while a lazy single-winner judgment shows one side's tokens dropped.
539
+ // A resolution may legitimately pick one side — but that becomes VISIBLE to the
540
+ // room instead of silently passing as a reconciliation. Never refuses.
541
+ const tokSet = (s) => new Set(String(s).match(/[A-Za-z_$]\w*|\d[\w.]*/g) || [])
542
+ const baseToks = tokSet(rec.base || ''), landedToks = tokSet(landed)
543
+ const coverage = rec.versions.map((v) => {
544
+ const vt = fe.versions.get(v.hash)
545
+ const changed = vt == null ? [] : [...tokSet(vt)].filter((t) => !baseToks.has(t))
546
+ const missing = changed.filter((t) => !landedToks.has(t))
547
+ return { name: v.name, covered: missing.length === 0, missing }
548
+ })
549
+ const dropped = coverage.filter((c) => !c.covered)
550
+ const block = forkBlock(fe, rec)
551
+ fe.doc.transact(() => {
552
+ applyDiff(fe.text, landed)
553
+ for (const vv of rec.versions) fe.forkmark.set(vv.hash, 1)
554
+ for (const key of [...fe.conflict.keys()]) fe.conflict.delete(key)
555
+ })
556
+ stampProvenance(relPath, block || rec.base || '', landed)
557
+ writeToDisk(relPath, landed)
558
+ conflicted.delete(relPath)
559
+ known.add(relPath); bases.set(relPath, landed); forkBases.set(relPath, landed)
560
+ const covNote = dropped.length
561
+ ? ` ⚠ Coverage: ${dropped.map((c) => `${c.name}'s changes (${c.missing.slice(0, 5).join(', ')}) did NOT survive`).join('; ')} — the room should sanity-check this judgment.`
562
+ : ' Full intent coverage: every side\'s changes survived token-level.'
563
+ try { say(`⚖ FORK RESOLVED by ${name} (intent-aware): reconciled ${rec.versions.map((v) => v.name).join(' + ')} in ${relPath}. Validated by ICR (parses, no dangling refs) before landing.${covNote}`) } catch { }
564
+ return { ok: true, file: relPath, text: landed, coverage }
565
+ }
566
+ // FIRST-CLASS AUTHORED WRITE — an agent writes THROUGH the medium instead of via
567
+ // disk + a debounced watcher. This closes the capture race COMPLETELY for the write:
568
+ // the author's declared intent is recorded and the (base -> text) transition is
569
+ // stamped into the provenance ledger synchronously, before any concurrent merge or
570
+ // conflict render could contaminate the disk (the fs.watch model's residual window
571
+ // does not exist on this path). The content then lands through the SAME reconcile
572
+ // pipeline as any local edit — merging, fork detection, and conflicts behave
573
+ // identically; only the capture is race-free.
574
+ function editFile(relPath, content, intent) {
575
+ relPath = String(relPath || '').replace(/\\/g, '/')
576
+ if (!relPath || typeof content !== 'string') return { error: 'need a file path and string content' }
577
+ if (!isSafeRelPath(relPath)) return { error: 'unsafe path' }
578
+ if (SKIP.has(relPath) || isIgnored(relPath)) return { error: `${relPath} is not a syncable file` }
579
+ if (!canOpen(relPath)) return { error: `${relPath} is out of your scope` }
580
+ if (!canWrite(relPath)) return { error: `${relPath} is read-only for you` }
581
+ if (intent) myIntents.set(relPath, String(intent)) // my declared WHY — what my receipt will sign
582
+ const full = path.join(ROOT, relPath)
583
+ try { fs.mkdirSync(path.dirname(full), { recursive: true }); fs.writeFileSync(full, content) }
584
+ catch (e) { return { error: 'write failed: ' + e.message } }
585
+ // Deliberately NOT via writeToDisk (which records engine writes in `shadow`):
586
+ // this write is AUTHORED, so disk-vs-shadow divergence is exactly what captureLocal
587
+ // stamps. Run the pass NOW — no debounce window at all.
588
+ if (FORK_GATE) captureLocal(relPath)
589
+ reconcile(relPath, 'local')
590
+ const fe = fileDocs.get(relPath)
591
+ const landed = fe && fe.synced ? fe.text.toString() : content
592
+ let disk = content; try { disk = fs.readFileSync(full, 'utf8') } catch { }
593
+ return { ok: true, file: relPath, landed, conflicted: hasConflictMarkers(disk) }
594
+ }
461
595
  // Detect a live fork and record it. The version set is a CRDT UNION (per-hash keys), so
462
596
  // this is monotonic + convergent: every peer contributes what it sees and the set grows
463
597
  // to the full fork on all peers regardless of arrival order. Does NOT touch Y.Text.
@@ -731,7 +865,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
731
865
  res = { text: disk, conflict: false }
732
866
  } else if (origin === 'local') {
733
867
  noteIfRewrite(relPath, fork, disk)
734
- // THE FIX. Merge a local edit against the FORK POINT (what this author
868
+ // Check if this write is an intentional clean rewrite
869
+ const sc = summarizeChange(fork, disk)
870
+ const isCleanRewrite = sc.isRewrite && supports(relPath) && languageFor(relPath).parses(disk)
871
+
872
+ // Merge a local edit against the FORK POINT (what this author
735
873
  // last saw), not the latest doc. If the doc gained lines since the fork
736
874
  // that this write does NOT contain, the author was working from a stale
737
875
  // copy — merging against the fork re-adds those lines (disjoint) or raises
@@ -739,8 +877,8 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
739
877
  const theirsNew = changedRange(fork.split('\n'), docText.split('\n')).newLines.filter((l) => l.length)
740
878
  const mineLines = new Set(disk.split('\n'))
741
879
  const integrated = theirsNew.every((l) => mineLines.has(l)) // this write already contains the remote change
742
- if (integrated) {
743
- res = { text: disk, conflict: false } // author built on the latest — trust it
880
+ if (integrated || (isCleanRewrite && !hasConflictMarkers(docText))) {
881
+ res = { text: disk, conflict: false } // author built on the latest or cleanly rewrote — trust it
744
882
  } else {
745
883
  res = icrMerge3(fork, disk, docText, relPath)
746
884
  reAdded = !res.conflict && res.text !== disk // we restored remote lines a stale write had dropped
@@ -1208,10 +1346,54 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1208
1346
  return {
1209
1347
  doc,
1210
1348
  provider,
1349
+ // A STATE MACHINE, not a validated variable.
1350
+ //
1351
+ // `enum` alone makes this a notepad with spellcheck: it stops a typo and permits any value at any
1352
+ // time, so APPROVED can slide back to LOCKED and a release gate can be opened by an agent that
1353
+ // simply set the flag. Two additions turn it into a machine, and both exist because a live
1354
+ // four-agent build (JARVIS, 2026-07-29) was governed entirely by rules written in prose that four
1355
+ // capable agents read and then broke:
1356
+ //
1357
+ // transitions: { FROM: [TO, ...] } which moves are legal AT ALL
1358
+ // requires: { TO: { key: val } } what must already be true to arrive at TO
1359
+ //
1360
+ // `requires` is the important one. It makes a contract EXECUTABLE. "Nobody sends email until the
1361
+ // suppression UI exists and the unsubscribe URL is real" held for nine hours only because a PM
1362
+ // kept repeating it; expressed as
1363
+ // requires: { LIVE: { suppression_ui: 'APPROVED', unsubscribe_url: 'SET' } }
1364
+ // it holds because the transition cannot be made. That is the whole point of this layer: the
1365
+ // place where a rule stops being advice.
1211
1366
  setState: (key, val) => {
1212
1367
  const rules = swarmSchema.get(key)
1213
- if (rules && rules.enum && !rules.enum.includes(val)) {
1214
- throw new Error(`[Schema Error] Invalid state for [${key}]. Allowed values: ${rules.enum.join(', ')}`);
1368
+ const cur = swarmState.get(key)
1369
+ // PARAMETERISED VALUES. `PUBLISH:2` must satisfy an enum containing `PUBLISH` — otherwise the
1370
+ // two features collide: a leased grant is a PUBLISH carrying a count, and schema-ing the status
1371
+ // key would make every lease illegal. So a value of the form BASE:arg validates on BASE.
1372
+ // Found by trying to use both at once on a live room, which is the only way this surfaces.
1373
+ const base = String(val).includes(':') ? String(val).slice(0, String(val).indexOf(':')) : val
1374
+ if (rules && rules.enum && !rules.enum.includes(val) && !rules.enum.includes(base)) {
1375
+ throw new Error(`[Schema Error] Invalid state for [${key}]. Allowed values: ${rules.enum.join(', ')}${base !== val ? ` (parameterised values like ${base}:<n> are allowed when ${base} is listed)` : ''}`);
1376
+ }
1377
+ // Transitions are checked only when the key ALREADY has a value — the first write to a key is
1378
+ // initialisation, not a move, and demanding a `from` entry for it would make every schema
1379
+ // carry a synthetic start state.
1380
+ // Transitions compare on the BASE too, for the same reason as the enum above: a lease counting
1381
+ // down PUBLISH:2 -> PUBLISH:1 is one state decrementing its own parameter, not a move between
1382
+ // states, and demanding a transition entry per count would be absurd.
1383
+ const curBase = cur !== undefined && String(cur).includes(':') ? String(cur).slice(0, String(cur).indexOf(':')) : cur
1384
+ if (rules && rules.transitions && cur !== undefined && curBase !== base) {
1385
+ const allowed = rules.transitions[curBase] || rules.transitions[cur]
1386
+ if (!Array.isArray(allowed) || !(allowed.includes(val) || allowed.includes(base))) {
1387
+ throw new Error(`[Transition Error] [${key}] cannot go ${cur} -> ${val}. Legal from ${curBase}: ${Array.isArray(allowed) && allowed.length ? allowed.join(', ') : '(nothing — terminal state)'}`)
1388
+ }
1389
+ }
1390
+ if (rules && rules.requires && rules.requires[val]) {
1391
+ const unmet = Object.entries(rules.requires[val])
1392
+ .filter(([k, want]) => String(swarmState.get(k)) !== String(want))
1393
+ .map(([k, want]) => `${k}=${want} (is ${swarmState.get(k) === undefined ? 'unset' : swarmState.get(k)})`)
1394
+ if (unmet.length) {
1395
+ throw new Error(`[Guard Error] [${key}] cannot become ${val} until: ${unmet.join('; ')}`)
1396
+ }
1215
1397
  }
1216
1398
  swarmState.set(key, val)
1217
1399
  say(`STATE TRANSITION: [${key}] is now [${val}]`)
@@ -1225,6 +1407,30 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1225
1407
  swarmSchema.set(key, rules)
1226
1408
  say(`SCHEMA UPDATED: [${key}] rules set to ${JSON.stringify(rules)}`)
1227
1409
  },
1410
+ // --- attestation: does another folder actually hold the same bytes? ---
1411
+ // Records THIS peer's measurement of a path. Returns the merged picture including, crucially,
1412
+ // whether the peers DISAGREE — see the swarm_attest declaration for why it is keyed per peer.
1413
+ attest: (relPath, bytes, lines) => {
1414
+ const p = String(relPath)
1415
+ const prev = swarmAttest.get(p) || {}
1416
+ const next = { ...prev, [name]: { bytes: Number(bytes), lines: Number(lines), at: new Date().toISOString() } }
1417
+ swarmAttest.set(p, next)
1418
+ const sizes = [...new Set(Object.values(next).map((v) => v && v.bytes))]
1419
+ const split = sizes.length > 1
1420
+ if (split) {
1421
+ // Announce, do not throw. The measuring agent did nothing wrong and its report is valid; the
1422
+ // ROOM is what is broken. Throwing here would punish whoever happened to measure second.
1423
+ const detail = Object.entries(next).map(([who, v]) => `${who}:${v.bytes}b/${v.lines}L`).join(' ')
1424
+ say(`[!] DIVERGENCE on ${p} — peers hold DIFFERENT bytes: ${detail}. Nobody may write this path until it is reconciled; mark it path:${p}=POISONED if it cannot be.`)
1425
+ }
1426
+ return { path: p, peers: next, count: Object.keys(next).length, sizes, diverged: split }
1427
+ },
1428
+ verified: (relPath) => {
1429
+ const p = String(relPath)
1430
+ const rec = swarmAttest.get(p) || {}
1431
+ const sizes = [...new Set(Object.values(rec).map((v) => v && v.bytes))]
1432
+ return { path: p, peers: rec, count: Object.keys(rec).length, sizes, diverged: sizes.length > 1, quorum: Object.keys(rec).length >= 2 && sizes.length === 1 }
1433
+ },
1228
1434
  say, // post a coordination message
1229
1435
  assign, // direct a task at a participant (needs approval if target AI has an owner)
1230
1436
  decide, // approve/deny a task (owner only, if set)
@@ -1234,10 +1440,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
1234
1440
  isPaused, // is a participant currently paused?
1235
1441
  controls, // the live control map
1236
1442
  // --- hive coordination: claim a region BEFORE editing (collision prevention) ---
1237
- claim: (region, intent) => coordinator.acquire(String(region), String(intent || 'edit')), // true iff you hold it
1238
- release: (region) => coordinator.release(String(region)),
1443
+ 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)
1444
+ release: (region) => { const r = String(region); myIntents.delete(r); return coordinator.release(r) },
1239
1445
  senseClaim: (region) => coordinator.sense(String(region)), // the live claim on a region, or null
1240
1446
  claimsBoard: () => coordinator.board(), // everything currently claimed
1447
+ // --- intent-aware fork resolution: any agent may judge; the medium validates ---
1448
+ forkInfo, // the active fork on a file as a resolvable object (base + versions + signed intents), or null
1449
+ resolveFork, // propose the reconciled file; ICR-validated (parse + dangling) before it lands, refused otherwise
1450
+ edit: editFile, // first-class authored write: intent + transition stamped race-free, then normal reconcile
1241
1451
  // --- convergent substrate: provenance (I1) ---
1242
1452
  identity: () => identity ? { id: identity.id, name: identity.name, pk: identity.pk } : null, // never exposes the private key
1243
1453
  provenanceOf: (relPath) => { const e = fileDocs.get(relPath); return e && e.ledger ? e.ledger.toArray() : [] }, // signed receipts for a file
package/MCP.md DELETED
@@ -1,77 +0,0 @@
1
- # Hivecode MCP server
2
-
3
- Lets any MCP-capable AI agent (Claude Code, Claude Desktop, etc.) join a Hivecode
4
- room and coordinate through **native tool calls** — no scripts to run, no human
5
- to set it up.
6
-
7
- ## Register it
8
-
9
- Add Hivecode to your MCP client config. The command runs this repo's server.
10
-
11
- **Claude Code** (`.mcp.json` in your project, or user settings):
12
- ```json
13
- {
14
- "mcpServers": {
15
- "hivecode": {
16
- "command": "node",
17
- "args": ["C:/Users/G1/Desktop/N/hive-mcp.js"],
18
- "env": { "HIVE_RELAY": "wss://livecode-xoss.onrender.com" }
19
- }
20
- }
21
- }
22
- ```
23
-
24
- `HIVE_RELAY` is optional (defaults to the hosted relay).
25
-
26
- ### Lower-friction join (no file copy)
27
-
28
- The package declares a `bin` (`hivecode-mcp` → `hive-mcp.js`), so once it's
29
- published to npm the other agent needs no local copy/clone — just:
30
-
31
- ```json
32
- { "mcpServers": { "hivecode": { "command": "npx", "args": ["-y", "hivecode-mcp"] } } }
33
- ```
34
-
35
- `npx` fetches and runs it. (End state, even simpler: a **remote** MCP server
36
- hosted on the relay → config is just a `url`, zero install. Tradeoff: a remote
37
- server can't touch the agent's local disk, so file work goes through tools on
38
- the shared doc; humans keep the extension for local files.)
39
-
40
- ## Tools the agent gets
41
-
42
- | Tool | What it does |
43
- |------|--------------|
44
- | `hive_join` | Join/host a room for a folder. Auto-joins as an **AI** participant. Returns the room info **and the HIVE_RULES to follow**. |
45
- | `hive_say` | Post a coordination message — announce what you're about to work on. |
46
- | `hive_read_chat` | Read the room conversation (read this to coordinate). |
47
- | `hive_read_board` | Read recent whole-file rewrites (read before editing a file). |
48
- | `hive_members` | Who's in the room (humans + agents). |
49
- | `hive_status` | Current session info. |
50
- | `hive_leave` | Leave the room. |
51
-
52
- ## How an agent uses it
53
-
54
- 1. `hive_join` (with a `link`, a `dir` containing `.hive.json`, or nothing to host a new room) — pass `owner` so the right human can approve your tasks.
55
- 2. Read the returned **HIVE_RULES**.
56
- 3. `hive_read_chat` + `hive_read_board` before touching files.
57
- 4. `hive_say` to announce intent, then edit files in the synced folder.
58
- 5. Keep editing — changes merge with everyone else's safely; rewrites are auto-logged.
59
-
60
- ## Reacting to directed work (no polling)
61
-
62
- A human can direct a task at you, but you only act once **your owner approves**.
63
- Don't poll — **block** on `hive_wait`:
64
-
65
- ```
66
- loop:
67
- result = hive_wait(timeoutSeconds: 60) # returns the instant your owner approves
68
- if result has APPROVED WORK:
69
- do it, then hive_complete(id)
70
- # else just call hive_wait again
71
- ```
72
-
73
- `hive_wait` returns within ~1s of the approval (it reacts to the live update),
74
- and also wakes on new chat so you stay responsive to teammates.
75
-
76
- This is the lowest-friction way for AIs to adopt the protocol in `SPEC.md`:
77
- the agent never runs a command, it just calls tools.