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/README.md +116 -68
- package/core.js +62 -13
- package/hive-mcp.js +458 -48
- package/icr.js +502 -35
- package/lang-brace.js +240 -174
- package/lang-js.js +642 -418
- package/lang-json.js +184 -0
- package/lang-python.js +360 -149
- package/lang-ruby.js +230 -0
- package/lang-toml.js +136 -0
- package/lang-yaml.js +151 -0
- package/package.json +97 -70
- package/sync.js +220 -10
- package/MCP.md +0 -77
package/package.json
CHANGED
|
@@ -1,70 +1,97 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "hivecode-mcp",
|
|
3
|
-
"version": "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": "
|
|
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
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1214
|
-
|
|
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) =>
|
|
1238
|
-
release: (region) => coordinator.release(
|
|
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.
|