hivecode-mcp 0.7.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 +51 -84
- package/core.js +62 -13
- package/hive-mcp.js +515 -45
- package/icr.js +506 -34
- package/lang-brace.js +240 -0
- package/lang-js.js +642 -418
- package/lang-json.js +184 -0
- package/lang-python.js +360 -0
- package/lang-ruby.js +230 -0
- package/lang-toml.js +136 -0
- package/lang-yaml.js +151 -0
- package/package.json +97 -62
- package/substrate.js +252 -0
- package/sync.js +642 -13
- package/MCP.md +0 -77
package/sync.js
CHANGED
|
@@ -21,10 +21,12 @@ import * as Y from 'yjs'
|
|
|
21
21
|
import { WebsocketProvider } from 'y-websocket'
|
|
22
22
|
import { WebSocket } from 'ws'
|
|
23
23
|
import ignore from 'ignore'
|
|
24
|
-
import { applyDiff, summarizeChange, changedRange, hasConflictMarkers } from './core.js'
|
|
24
|
+
import { applyDiff, summarizeChange, changedRange, hasConflictMarkers, merge3 } from './core.js'
|
|
25
25
|
import { icrMerge3 } from './icr-merge.js' // structure/intent-aware merge; falls back to line merge3
|
|
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
|
+
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)
|
|
28
30
|
|
|
29
31
|
// Wire-format/protocol version. Bump when a change makes clients incompatible
|
|
30
32
|
// (e.g. the v2 per-file-subdoc model). Broadcast via awareness so peers can warn
|
|
@@ -36,6 +38,11 @@ const PROTOCOL_VERSION = 2
|
|
|
36
38
|
const ALWAYS_IGNORE = [
|
|
37
39
|
'.git/', 'node_modules/',
|
|
38
40
|
'.env', '.env.*', '*.pem', '*.key', '*.pfx', '*.p12', 'id_rsa', 'id_ed25519', '*.keystore',
|
|
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',
|
|
39
46
|
'*.log', '.DS_Store', 'Thumbs.db', '*.vsix',
|
|
40
47
|
]
|
|
41
48
|
|
|
@@ -45,12 +52,13 @@ const BOARD_FILE = 'HIVE_BOARD.md' // generated locally from `board`; never sync
|
|
|
45
52
|
const CHAT_FILE = 'HIVE_CHAT.md' // generated locally from `chat`; the agents' conversation
|
|
46
53
|
const TASKS_FILE = 'HIVE_TASKS.md' // generated locally from `tasks`; directed work + approvals
|
|
47
54
|
const CONFIG_FILE = '.hive.json' // local rendezvous config (room id); not synced
|
|
55
|
+
const ID_FILE = '.hive-id.json' // this client's substrate signing identity (private); never synced
|
|
48
56
|
const RULES_FILE = 'HIVE_RULES.md' // the law every participant follows; written into every room
|
|
49
57
|
const MEMBERS_FILE = 'HIVE_MEMBERS.md' // live presence: who is in the room right now
|
|
50
58
|
const AGENTS_FILE = 'HIVE_FOR_AGENTS.md' // how an AI agent opening this folder joins (via .hive.json) + behaves
|
|
51
59
|
// Generated/coordination files are rendered locally from CRDT state — never
|
|
52
60
|
// synced as ordinary files (that would cause echo loops / conflicts).
|
|
53
|
-
const SKIP = new Set([BOARD_FILE, CHAT_FILE, TASKS_FILE, CONFIG_FILE, RULES_FILE, MEMBERS_FILE, AGENTS_FILE])
|
|
61
|
+
const SKIP = new Set([BOARD_FILE, CHAT_FILE, TASKS_FILE, CONFIG_FILE, ID_FILE, RULES_FILE, MEMBERS_FILE, AGENTS_FILE])
|
|
54
62
|
|
|
55
63
|
// Written into every room folder on sync (like the rules). An AI agent that opens
|
|
56
64
|
// this project reads it to learn it's a Hivecode room and how to JOIN — via the
|
|
@@ -139,8 +147,10 @@ another's work. The sync layer enforces the hard parts automatically; you do the
|
|
|
139
147
|
refactor) that touches another participant's area.
|
|
140
148
|
|
|
141
149
|
## Directed work + permission (the asymmetric gate)
|
|
142
|
-
11. AI -> AI is COORDINATION: if another agent hands you work, it is auto-accepted
|
|
143
|
-
and you may proceed.
|
|
150
|
+
11. AI -> AI is COORDINATION: if another agent hands you work (via hive_assign), it is auto-accepted
|
|
151
|
+
and you may proceed. If there is no Lead PM setting global state, use hive_assign and hive_say
|
|
152
|
+
to assign tasks directly to other agents to divide and conquer. This is how a leaderless hive
|
|
153
|
+
plans and divides work by itself.
|
|
144
154
|
12. A HUMAN directing you is different. If a human who is NOT your owner assigns
|
|
145
155
|
you a task, you do NOT act on it — it stays PENDING until YOUR OWNER approves
|
|
146
156
|
("do it or ignore?"). Your own owner's requests proceed. Act only on tasks
|
|
@@ -165,6 +175,11 @@ another's work. The sync layer enforces the hard parts automatically; you do the
|
|
|
165
175
|
16. YOU triage: announce do-now vs after-current. Your OWNER can override anytime
|
|
166
176
|
— if your owner says "do it now" or "skip that", that wins.
|
|
167
177
|
|
|
178
|
+
## State-Machine Prompting (SMP)
|
|
179
|
+
17. The Lead PM controls the [GLOBAL PROJECT STATE] via hive_set_state.
|
|
180
|
+
18. ONLY use short enum flags (e.g. "APPROVED", "LOCKED"). Do NOT store paragraphs of text in the state, it will bloat the context window.
|
|
181
|
+
19. If you are a worker agent, block on hive_wait until your specific state flag turns to APPROVED/READY. Do not write code if you are LOCKED.
|
|
182
|
+
|
|
168
183
|
Read → announce → patch → respect lanes → resolve conflicts → talk → triage pings → wait for approval.
|
|
169
184
|
`
|
|
170
185
|
|
|
@@ -204,7 +219,404 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
204
219
|
// Agents SENSE → FLOW → CLAIM before editing so they don't collide. No central controller;
|
|
205
220
|
// claims evaporate via TTL so a stalled agent never deadlocks the hive.
|
|
206
221
|
const claims = doc.getMap('claims')
|
|
222
|
+
const swarmState = doc.getMap('swarm_state')
|
|
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')
|
|
207
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()
|
|
243
|
+
|
|
244
|
+
// --- convergent substrate: signed provenance (opt-in via HIVE_PROVENANCE) ------
|
|
245
|
+
// When on, this client loads (or mints) a persistent Ed25519 identity and SIGNS
|
|
246
|
+
// every change it lands, appending a verifiable receipt to each file's provenance
|
|
247
|
+
// ledger. This makes invariant I1 (provenance-verified) real end-to-end: every
|
|
248
|
+
// state the room ever holds is attributable to a cryptographic author + a declared
|
|
249
|
+
// intent. Everything here is wrapped so a failure can NEVER break sync — provenance
|
|
250
|
+
// is an added guarantee, not a new dependency of the live path.
|
|
251
|
+
const PROVENANCE = (process.env.HIVE_PROVENANCE || 'off').toLowerCase() !== 'off'
|
|
252
|
+
// The live SILENT-FORK GATE (detect + surface concurrent same-line edits) is EXPERIMENTAL
|
|
253
|
+
// and OFF by default. It reliably surfaces conflicts for realistic (2-3 agent, non-burst)
|
|
254
|
+
// concurrency, but robustly detecting forks needs a source-level capture of each authored
|
|
255
|
+
// edit BEFORE it merges — the fs.watch/disk-diff model contaminates the authored version,
|
|
256
|
+
// so detection can't be made both complete (catch every clash) and sound (never false-flag
|
|
257
|
+
// a disjoint edit). Opt in with HIVE_FORK_GATE=on. When off, sync behaves exactly as the
|
|
258
|
+
// proven core: provenance still signs every change; only the fork surfacing is disabled.
|
|
259
|
+
const FORK_GATE = PROVENANCE && (process.env.HIVE_FORK_GATE || 'off').toLowerCase() !== 'off'
|
|
260
|
+
let identity = null
|
|
261
|
+
if (PROVENANCE) {
|
|
262
|
+
const idPath = path.join(ROOT, ID_FILE)
|
|
263
|
+
try { if (fs.existsSync(idPath)) { const j = JSON.parse(fs.readFileSync(idPath, 'utf8')); if (j && j.pk && j.sk && j.id) identity = j } } catch { identity = null }
|
|
264
|
+
if (!identity) { try { identity = genIdentity(name); fs.writeFileSync(idPath, JSON.stringify(identity, null, 2)) } catch { identity = null } }
|
|
265
|
+
if (identity) log(`[${name}] substrate provenance ON — signing as ${identity.id.slice(0, 12)}…`)
|
|
266
|
+
}
|
|
267
|
+
// Record a signed receipt for a landed local change (base -> next) on a file's
|
|
268
|
+
// ledger. Intent is taken from this author's live claim on the region, so the
|
|
269
|
+
// WHY that the coordinator already knows is what gets cryptographically bound.
|
|
270
|
+
// Two concerns, deliberately decoupled:
|
|
271
|
+
// stampIntent -> LEDGER receipt: the author's INTENT — the state they edited FROM (base)
|
|
272
|
+
// -> the content they WROTE (next). With the fork gate on this comes from the SOURCE
|
|
273
|
+
// capture (captureLocal), so it is anchored to a clean base, not the fusing shared
|
|
274
|
+
// text — which is what keeps concurrent same-line edits fork-detectable.
|
|
275
|
+
// attestHead -> HEAD: content AUTHORITY — attests the ACTUAL landed (converged) content,
|
|
276
|
+
// so the relay's non-regression guard always judges real content, never partial intent.
|
|
277
|
+
function stampIntent(relPath, base, next) {
|
|
278
|
+
if (!PROVENANCE || !identity) return
|
|
279
|
+
const fe = fileDocs.get(relPath); if (!fe || !fe.ledger) return
|
|
280
|
+
// Idempotent: if THIS author already signed this exact (parent -> content) transition,
|
|
281
|
+
// don't duplicate it — the source capture and the edit path may both stamp the same change.
|
|
282
|
+
const pHash = contentHash(base == null ? '' : base), cHash0 = contentHash(next)
|
|
283
|
+
if (fe.ledger.toArray().some((r) => r && r.author === identity.id && r.parent === pHash && r.contentHash === cHash0)) return
|
|
284
|
+
try {
|
|
285
|
+
const c = coordinator.sense(relPath)
|
|
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() })
|
|
288
|
+
// `name` is display-only (NOT signed) — the verified identity is `author` (the key
|
|
289
|
+
// fingerprint). A UI shows both: the friendly name, and the cryptographic author it
|
|
290
|
+
// must match. Signature covers everything else, so tampering `name` can't fake trust.
|
|
291
|
+
const receipt = { ...ch.prov, name }
|
|
292
|
+
fe.doc.transact(() => {
|
|
293
|
+
fe.ledger.push([receipt]) // append INTENT to history (audit + fork detection)
|
|
294
|
+
// Preserve the FULL TEXT of both states, keyed by content hash — the raw material
|
|
295
|
+
// fork detection reconstructs pre-fusion versions from after the char-CRDT fuses them.
|
|
296
|
+
storeVersion(fe, base == null ? '' : base)
|
|
297
|
+
storeVersion(fe, next)
|
|
298
|
+
})
|
|
299
|
+
} catch { /* provenance must never break the sync path */ }
|
|
300
|
+
}
|
|
301
|
+
function attestHead(relPath, landed, base) {
|
|
302
|
+
if (!PROVENANCE || !identity || landed == null) return
|
|
303
|
+
const fe = fileDocs.get(relPath); if (!fe || !fe.head) return
|
|
304
|
+
try {
|
|
305
|
+
const cur = fe.head.get('cur')
|
|
306
|
+
if (cur && cur.hash === contentHash(landed)) return // head already attests this content
|
|
307
|
+
const c = coordinator.sense(relPath)
|
|
308
|
+
const at = Date.now()
|
|
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 })
|
|
311
|
+
fe.doc.transact(() => {
|
|
312
|
+
fe.head.set('cur', { text: landed, hash: ch.prov.contentHash, at, by: name, receipt: { ...ch.prov, name } })
|
|
313
|
+
storeVersion(fe, landed)
|
|
314
|
+
})
|
|
315
|
+
} catch { /* provenance must never break the sync path */ }
|
|
316
|
+
}
|
|
317
|
+
// Compatibility wrapper: record intent AND attest the landed content (defaults to next).
|
|
318
|
+
function stampProvenance(relPath, base, next, landed) {
|
|
319
|
+
stampIntent(relPath, base, next)
|
|
320
|
+
attestHead(relPath, landed == null ? next : landed, base)
|
|
321
|
+
}
|
|
322
|
+
// Record text by its content hash, bounded: drop any stored version no longer
|
|
323
|
+
// referenced by a receipt (as a parent or a result) so the map can't grow forever.
|
|
324
|
+
function storeVersion(fe, text) {
|
|
325
|
+
if (!fe || !fe.versions || text == null) return
|
|
326
|
+
fe.versions.set(contentHash(text), text)
|
|
327
|
+
if (fe.versions.size > 64) {
|
|
328
|
+
const live = new Set()
|
|
329
|
+
for (const r of fe.ledger.toArray()) { if (r && r.parent) live.add(r.parent); if (r && r.contentHash) live.add(r.contentHash) }
|
|
330
|
+
for (const h of [...fe.versions.keys()]) if (!live.has(h)) fe.versions.delete(h)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// SOURCE-LEVEL EDIT CAPTURE — the subsystem that makes fork detection sound.
|
|
334
|
+
// A room folder has exactly TWO writers: this engine (every write it makes is recorded in
|
|
335
|
+
// `shadow`) and the local author (their editor/agent — everything else). So any divergence
|
|
336
|
+
// of disk from shadow IS an authored edit, and (shadow -> disk) is its true, uncontaminated
|
|
337
|
+
// (base -> text) pair — captured and signed BEFORE any merge or conflict render can
|
|
338
|
+
// overwrite the file. This is what after-the-fact disk-diffing could never provide: the
|
|
339
|
+
// author's version unpolluted by concurrent merges. It closes the capture race: the old
|
|
340
|
+
// window was the whole watcher debounce; now capture runs at the entry of every pass that
|
|
341
|
+
// could clobber, so an authored edit enters the provenance DAG before anything overwrites
|
|
342
|
+
// it. Gate-on only; the proven default path is untouched.
|
|
343
|
+
function captureLocal(relPath) {
|
|
344
|
+
if (!FORK_GATE || !identity) return false
|
|
345
|
+
const fe = fileDocs.get(relPath); if (!fe || !fe.synced || !fe.ledger) return false
|
|
346
|
+
if (SKIP.has(relPath) || isIgnored(relPath)) return false
|
|
347
|
+
let disk = null; try { disk = fs.readFileSync(path.join(ROOT, relPath), 'utf8') } catch { return false }
|
|
348
|
+
const base = shadow.get(relPath)
|
|
349
|
+
if (base == null || disk === base) return false // no baseline yet, or nothing new on disk
|
|
350
|
+
if (hasConflictMarkers(disk)) return false // half-edited conflict block — not an authored state
|
|
351
|
+
stampIntent(relPath, base, disk) // into the provenance DAG NOW — clobber-proof
|
|
352
|
+
shadow.set(relPath, disk) // the captured state is the new baseline
|
|
353
|
+
return true
|
|
354
|
+
}
|
|
355
|
+
// Build ONE clean N-way conflict block from a common parent and N divergent versions:
|
|
356
|
+
// strip the lines they (and the parent) all share as a prefix/suffix, then stack each
|
|
357
|
+
// version's differing middle between markers. Deterministic (caller sorts versions), so
|
|
358
|
+
// every peer produces byte-identical output. Returns null if nothing distinct remains.
|
|
359
|
+
function nwayConflict(parentText, versions) {
|
|
360
|
+
const P = parentText.split('\n')
|
|
361
|
+
const Vs = versions.map((v) => v.text.split('\n'))
|
|
362
|
+
const minLen = Math.min(P.length, ...Vs.map((v) => v.length))
|
|
363
|
+
let pre = 0
|
|
364
|
+
while (pre < minLen && Vs.every((v) => v[pre] === P[pre])) pre++
|
|
365
|
+
let suf = 0
|
|
366
|
+
while (suf < minLen - pre && Vs.every((v) => v[v.length - 1 - suf] === P[P.length - 1 - suf])) suf++
|
|
367
|
+
const out = [...P.slice(0, pre)]
|
|
368
|
+
let any = false
|
|
369
|
+
versions.forEach((v, i) => {
|
|
370
|
+
const mid = Vs[i].slice(pre, Vs[i].length - suf)
|
|
371
|
+
if (mid.length) any = true
|
|
372
|
+
out.push(`${i === 0 ? '<<<<<<<' : '======='} ${v.name} (${String(v.hash).slice(0, 7)})`)
|
|
373
|
+
out.push(...mid)
|
|
374
|
+
})
|
|
375
|
+
// Closing marker MUST carry a trailing label — core.js hasConflictMarkers matches
|
|
376
|
+
// /^>>>>>>> /m (note the space), so a bare ">>>>>>>" would not register as a conflict
|
|
377
|
+
// and the sync layer would treat the block as clean content.
|
|
378
|
+
out.push('>>>>>>> (resolve: keep one, delete the markers)')
|
|
379
|
+
out.push(...P.slice(P.length - suf))
|
|
380
|
+
return any ? out.join('\n') : null
|
|
381
|
+
}
|
|
382
|
+
// SILENT-FORK DETECTION — the health gate, done RIGHT (local-render model).
|
|
383
|
+
//
|
|
384
|
+
// The char-CRDT (Y.Text) always converges, even when it shouldn't: two agents editing
|
|
385
|
+
// the SAME line fuse into valid-but-unauthored code ("return 2" + "return 3" -> "return
|
|
386
|
+
// 32") that no layer flags. Nobody signs that fused byte-string, yet BOTH real versions
|
|
387
|
+
// were signed against the SAME parent. So the provenance ledger still holds the truth
|
|
388
|
+
// the char-merge destroyed.
|
|
389
|
+
//
|
|
390
|
+
// The KEY design choice: we do NOT write conflict markers back into the shared Y.Text
|
|
391
|
+
// (that just makes the markers themselves fuse — the old bug). Instead a fork is recorded
|
|
392
|
+
// as a deterministic, converged RECORD (a per-file LWW register, computed purely from the
|
|
393
|
+
// ledger so every peer derives the identical value), and each peer renders the markers to
|
|
394
|
+
// its OWN disk. Nothing concurrent is written to the shared text, so nothing can tangle.
|
|
395
|
+
// Y.Text is frozen at the fused value and simply ignored until a human resolves.
|
|
396
|
+
|
|
397
|
+
// Compute the live fork purely from the ledger. Same input -> same output on every peer,
|
|
398
|
+
// so setting the record is idempotent and needs no applier election. Returns
|
|
399
|
+
// { parent, base, versions:[{hash,name,author}] } or null.
|
|
400
|
+
function computeFork(fe) {
|
|
401
|
+
const receipts = fe.ledger.toArray()
|
|
402
|
+
if (!receipts.length) return null
|
|
403
|
+
const builtOn = new Set(receipts.map((r) => r && r.parent).filter(Boolean))
|
|
404
|
+
const byParent = new Map()
|
|
405
|
+
for (const r of receipts) {
|
|
406
|
+
if (!r || !r.parent || !r.contentHash) continue
|
|
407
|
+
if (!byParent.has(r.parent)) byParent.set(r.parent, new Map())
|
|
408
|
+
byParent.get(r.parent).set(r.contentHash, r) // dedupe divergent children by content
|
|
409
|
+
}
|
|
410
|
+
for (const [parent, kids] of byParent) {
|
|
411
|
+
if (kids.size < 2) continue
|
|
412
|
+
const base = fe.versions.get(parent)
|
|
413
|
+
if (base == null) continue
|
|
414
|
+
let arr = [...kids.values()]
|
|
415
|
+
.map((r) => ({ r, text: fe.versions.get(r.contentHash) }))
|
|
416
|
+
.filter((x) => x.text != null && !fe.forkmark.has(x.r.contentHash)) // skip versions already folded into a resolved conflict
|
|
417
|
+
const tips = arr.filter((x) => !builtOn.has(x.r.contentHash)) // prefer live heads
|
|
418
|
+
if (tips.length >= 2) arr = tips
|
|
419
|
+
if (arr.length < 2) continue
|
|
420
|
+
arr.sort((a, b) => (a.r.contentHash < b.r.contentHash ? -1 : 1)) // deterministic order across peers
|
|
421
|
+
// Discriminator: a REAL same-region fork, or disjoint edits the char-merge combined
|
|
422
|
+
// fine? If no pair actually conflicts on a 3-way against the base, leave it.
|
|
423
|
+
let real = false
|
|
424
|
+
for (let i = 0; i < arr.length && !real; i++)
|
|
425
|
+
for (let j = i + 1; j < arr.length && !real; j++) {
|
|
426
|
+
const m = merge3(base, arr[i].text, arr[j].text)
|
|
427
|
+
if (m.conflict || hasConflictMarkers(m.text)) real = true
|
|
428
|
+
}
|
|
429
|
+
if (!real) continue
|
|
430
|
+
return { parent, base, versions: arr.map((x) => ({ hash: x.r.contentHash, name: x.r.name, author: x.r.author })) }
|
|
431
|
+
}
|
|
432
|
+
return null
|
|
433
|
+
}
|
|
434
|
+
// The deterministic conflict block for a record (byte-identical on every peer).
|
|
435
|
+
function forkBlock(fe, rec) {
|
|
436
|
+
const versions = rec.versions.map((v) => ({ text: fe.versions.get(v.hash), name: v.name, hash: v.hash })).filter((v) => v.text != null)
|
|
437
|
+
if (versions.length < 2) return null
|
|
438
|
+
return nwayConflict(rec.base, versions)
|
|
439
|
+
}
|
|
440
|
+
// Read the ACTIVE fork from the conflict map. The version set is stored as a UNION of
|
|
441
|
+
// per-hash keys ('v:'+hash) that MERGE across peers (not a single LWW register), so a
|
|
442
|
+
// late-arriving version can never be lost to a last-writer-wins race — the set only grows
|
|
443
|
+
// and converges to the full set on every peer. Returns { parent, base, versions } or null.
|
|
444
|
+
function forkRecord(fe) {
|
|
445
|
+
if (!fe || !fe.conflict) return null
|
|
446
|
+
const meta = fe.conflict.get('meta'); if (!meta) return null
|
|
447
|
+
const versions = []
|
|
448
|
+
for (const key of fe.conflict.keys()) {
|
|
449
|
+
if (!key.startsWith('v:')) continue
|
|
450
|
+
const v = fe.conflict.get(key)
|
|
451
|
+
versions.push({ hash: key.slice(2), name: v.name, author: v.author })
|
|
452
|
+
}
|
|
453
|
+
if (versions.length < 2) return null
|
|
454
|
+
versions.sort((a, b) => (a.hash < b.hash ? -1 : 1)) // deterministic order across peers
|
|
455
|
+
return { parent: meta.parent, base: meta.base, versions }
|
|
456
|
+
}
|
|
457
|
+
// Render the active conflict to THIS peer's disk (local only — never Y.Text).
|
|
458
|
+
function renderConflict(relPath) {
|
|
459
|
+
const fe = fileDocs.get(relPath); if (!fe || !fe.conflict) return
|
|
460
|
+
// Before we overwrite this file with the conflict block, capture any local edit sitting
|
|
461
|
+
// on disk that hasn't been stamped yet — otherwise rendering would silently destroy it.
|
|
462
|
+
if (captureLocal(relPath)) { detectFork(relPath); return } // it grew the fork; detectFork re-renders with it included
|
|
463
|
+
const rec = forkRecord(fe); if (!rec) return
|
|
464
|
+
const block = forkBlock(fe, rec); if (!block) return
|
|
465
|
+
let disk = null; try { disk = fs.readFileSync(path.join(ROOT, relPath), 'utf8') } catch { }
|
|
466
|
+
forkBases.set(relPath, block) // this peer has now SEEN the markers — a later clean edit from here is a resolution
|
|
467
|
+
if (disk === block) return
|
|
468
|
+
writeToDisk(relPath, block)
|
|
469
|
+
if (!conflicted.has(relPath)) {
|
|
470
|
+
conflicted.add(relPath)
|
|
471
|
+
const who = rec.versions.map((v) => v.name).join(', ')
|
|
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).`)
|
|
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 { }
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// A resolution landed elsewhere: the shared text is clean again. Adopt it to disk.
|
|
480
|
+
function adoptResolved(relPath) {
|
|
481
|
+
const fe = fileDocs.get(relPath); if (!fe) return
|
|
482
|
+
const dt = fe.text.toString()
|
|
483
|
+
if (hasConflictMarkers(dt)) return
|
|
484
|
+
captureLocal(relPath) // an authored edit may be sitting on disk — record it before adopting over it
|
|
485
|
+
writeToDisk(relPath, dt)
|
|
486
|
+
bases.set(relPath, dt); forkBases.set(relPath, dt)
|
|
487
|
+
conflicted.delete(relPath)
|
|
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
|
+
}
|
|
595
|
+
// Detect a live fork and record it. The version set is a CRDT UNION (per-hash keys), so
|
|
596
|
+
// this is monotonic + convergent: every peer contributes what it sees and the set grows
|
|
597
|
+
// to the full fork on all peers regardless of arrival order. Does NOT touch Y.Text.
|
|
598
|
+
function detectFork(relPath) {
|
|
599
|
+
if (!FORK_GATE) return
|
|
600
|
+
const fe = fileDocs.get(relPath)
|
|
601
|
+
if (!fe || !fe.ledger || !fe.versions || !fe.conflict || !fe.synced) return
|
|
602
|
+
const rec = computeFork(fe)
|
|
603
|
+
if (fe.conflict.has('meta')) {
|
|
604
|
+
// Already in conflict — union in any newly-seen versions (never overwrites; only adds).
|
|
605
|
+
if (rec) fe.doc.transact(() => { for (const v of rec.versions) if (!fe.conflict.has('v:' + v.hash)) fe.conflict.set('v:' + v.hash, { name: v.name, author: v.author }) })
|
|
606
|
+
renderConflict(relPath)
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
const cur = fe.text.toString()
|
|
610
|
+
// Current content is itself a signed/authored state (one side won cleanly, or a human
|
|
611
|
+
// already resolved) — not a silent fusion.
|
|
612
|
+
if (fe.ledger.toArray().some((r) => r && r.contentHash === contentHash(cur))) return
|
|
613
|
+
if (!rec) return
|
|
614
|
+
fe.doc.transact(() => {
|
|
615
|
+
fe.conflict.set('meta', { parent: rec.parent, base: rec.base })
|
|
616
|
+
for (const v of rec.versions) fe.conflict.set('v:' + v.hash, { name: v.name, author: v.author })
|
|
617
|
+
})
|
|
618
|
+
renderConflict(relPath)
|
|
619
|
+
}
|
|
208
620
|
// disableBc: do NOT sync peer-to-peer over BroadcastChannel. The relay must be
|
|
209
621
|
// the ONLY path between clients — otherwise two clients on the same machine would
|
|
210
622
|
// sync directly and bypass the relay's access control (auth, path scope, read-only).
|
|
@@ -229,11 +641,38 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
229
641
|
// `undo` is a per-file UndoManager tracking THIS client's local edits, so a
|
|
230
642
|
// human/agent can undo their own CRDT changes (Level-0 rollback).
|
|
231
643
|
const snap = fdoc.getMap('snap')
|
|
644
|
+
// The file's provenance LEDGER: an append-only list of signed receipts, one per
|
|
645
|
+
// landed change. Lives in the file's own doc, so it inherits the file's access
|
|
646
|
+
// control and travels with it. Any peer can verifyReceipt() every entry.
|
|
647
|
+
const ledger = fdoc.getArray('ledger')
|
|
648
|
+
// The authoritative HEAD: a single signed pointer at the file's CURRENT content
|
|
649
|
+
// (key 'cur' -> { text, hash, at, by, receipt }). Content authority = the relay
|
|
650
|
+
// guarantees this is always a verified, non-regressing attestation of the content.
|
|
651
|
+
const head = fdoc.getMap('head')
|
|
652
|
+
// versions: contentHash -> full text, for every state any author edited from or
|
|
653
|
+
// produced. The char-CRDT keeps only the fused current bytes; this keeps the
|
|
654
|
+
// pre-fusion originals so a silent fork can be reconstructed and surfaced.
|
|
655
|
+
const versions = fdoc.getMap('versions')
|
|
656
|
+
// forkmark: contentHash -> 1 for every version already folded into a surfaced
|
|
657
|
+
// conflict. Synced, so once ANY peer surfaces a fork, those versions never trigger
|
|
658
|
+
// again — the conflict stays put until a human resolves it, and a resolution can't
|
|
659
|
+
// be re-conflicted by a peer whose ledger lags its content (the re-fork race).
|
|
660
|
+
const forkmark = fdoc.getMap('forkmark')
|
|
661
|
+
// conflict: the per-file LWW fork RECORD (key 'cur' -> { parent, base, versions }).
|
|
662
|
+
// Deterministic (computed from the ledger), converged across peers, and rendered to
|
|
663
|
+
// each peer's DISK — never into the shared text — so conflict markers can't fuse.
|
|
664
|
+
const conflict = fdoc.getMap('conflict')
|
|
232
665
|
const undo = new Y.UndoManager(text, { captureTimeout: 350 }) // default tracks local-origin txns
|
|
233
|
-
e = { doc: fdoc, provider: fprovider, text, snap, undo, synced: false }
|
|
666
|
+
e = { doc: fdoc, provider: fprovider, text, snap, ledger, head, versions, forkmark, conflict, undo, synced: false }
|
|
234
667
|
fileDocs.set(relPath, e)
|
|
235
668
|
text.observe((_ev, txn) => { if (!txn.local) reconcile(relPath, 'remote') }) // remote content edits
|
|
236
|
-
|
|
669
|
+
// A divergent receipt can arrive slightly after (or before) the fused content — so
|
|
670
|
+
// re-run the fork check whenever the provenance ledger changes too, not only on edits.
|
|
671
|
+
ledger.observe((_ev, txn) => { if (!txn.local) detectFork(relPath) })
|
|
672
|
+
// The fork record arrived/changed from a peer: render it locally, or — if it was
|
|
673
|
+
// cleared (resolved elsewhere) — adopt the now-clean shared text to disk.
|
|
674
|
+
conflict.observe((_ev, txn) => { if (txn.local) return; if (e.conflict.has('meta')) renderConflict(relPath); else adoptResolved(relPath) })
|
|
675
|
+
fprovider.on('sync', (s) => { if (s) { e.synced = true; reconcile(relPath, 'remote'); detectFork(relPath) } }) // initial pull settled
|
|
237
676
|
return e
|
|
238
677
|
}
|
|
239
678
|
function closeFile(relPath) {
|
|
@@ -264,6 +703,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
264
703
|
|
|
265
704
|
const known = new Set()
|
|
266
705
|
const mtimes = new Map()
|
|
706
|
+
const shadow = new Map() // relPath -> exact bytes THIS ENGINE last wrote/captured (source-level edit capture)
|
|
267
707
|
const bases = new Map() // last synced content per file (advances on every reconcile)
|
|
268
708
|
const forkBases = new Map() // FORK POINT: what THIS author last saw/authored per file.
|
|
269
709
|
// forkBases advances only on local authorship / first adoption — NOT when a
|
|
@@ -297,6 +737,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
297
737
|
const full = path.join(ROOT, relPath)
|
|
298
738
|
fs.mkdirSync(path.dirname(full), { recursive: true })
|
|
299
739
|
fs.writeFileSync(full, content)
|
|
740
|
+
shadow.set(relPath, content) // record what THE ENGINE wrote — any later disk divergence is an authored edit
|
|
300
741
|
known.add(relPath)
|
|
301
742
|
try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
|
|
302
743
|
}
|
|
@@ -327,13 +768,63 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
327
768
|
let fe = fileDocs.get(relPath)
|
|
328
769
|
if (!fe) { if (disk === null) return; openFile(relPath); return }
|
|
329
770
|
if (!fe.synced) return
|
|
771
|
+
// SOURCE CAPTURE (fork gate): before ANYTHING in this pass can merge over or render into
|
|
772
|
+
// this file, record any authored edit sitting on disk. The edit enters the provenance
|
|
773
|
+
// DAG first; whatever happens to the disk afterwards can no longer lose it.
|
|
774
|
+
if (FORK_GATE) captureLocal(relPath)
|
|
775
|
+
// --- provenance conflict state machine (local-render model) ---
|
|
776
|
+
// While a file is in a surfaced fork, its markers live ONLY on disk; the shared
|
|
777
|
+
// Y.Text is left at the fused value. This is what avoids the marker-fusion the old
|
|
778
|
+
// in-CRDT approach suffered — nothing conflict-related is ever written to Y.Text.
|
|
779
|
+
if (FORK_GATE && fe.conflict) {
|
|
780
|
+
const rec = forkRecord(fe)
|
|
781
|
+
if (rec) {
|
|
782
|
+
const block = forkBlock(fe, rec)
|
|
783
|
+
if (origin === 'local') {
|
|
784
|
+
if (disk === block) return // our own rendered conflict — ignore
|
|
785
|
+
const fork0 = forkBases.get(relPath)
|
|
786
|
+
// Did THIS author edit FROM the conflict block (i.e. they saw the markers)? Only
|
|
787
|
+
// then is a clean edit a RESOLUTION. A clean edit from a NON-marked base is just a
|
|
788
|
+
// concurrent authored edit that happened during the fork — it must NOT be mistaken
|
|
789
|
+
// for a resolution, or that agent's work silently replaces everyone's conflict.
|
|
790
|
+
const sawMarkers = fork0 === block || hasConflictMarkers(fork0 || '')
|
|
791
|
+
if (sawMarkers && disk !== null && !hasConflictMarkers(disk)) {
|
|
792
|
+
// RESOLUTION: a human replaced the markers with clean code. Publish it to the
|
|
793
|
+
// shared text, fold the fork versions, and clear the record for everyone.
|
|
794
|
+
fe.doc.transact(() => {
|
|
795
|
+
applyDiff(fe.text, disk)
|
|
796
|
+
for (const v of rec.versions) fe.forkmark.set(v.hash, 1)
|
|
797
|
+
for (const key of [...fe.conflict.keys()]) fe.conflict.delete(key)
|
|
798
|
+
})
|
|
799
|
+
stampProvenance(relPath, block || '', disk)
|
|
800
|
+
conflicted.delete(relPath)
|
|
801
|
+
known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
|
|
802
|
+
try { say(`✓ conflict in ${relPath} resolved by ${name}.`) } catch { }
|
|
803
|
+
return
|
|
804
|
+
}
|
|
805
|
+
if (!sawMarkers && disk !== null && disk !== fork0) {
|
|
806
|
+
// A concurrent authored edit landed WHILE the fork was open (this agent never saw
|
|
807
|
+
// the markers). The source capture at the top of this pass already stamped it, so
|
|
808
|
+
// it is in the fork's version set — re-detect so the record grows to include it.
|
|
809
|
+
detectFork(relPath)
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
renderConflict(relPath); return // still conflicted (or partially edited) — re-assert canonical block
|
|
813
|
+
}
|
|
814
|
+
renderConflict(relPath); return // remote churn while in conflict — keep our local render
|
|
815
|
+
}
|
|
816
|
+
// No active record, but our disk still shows stale markers while the shared text is
|
|
817
|
+
// clean again -> a resolution landed elsewhere; adopt it (order-independent w/ observer).
|
|
818
|
+
if (disk !== null && hasConflictMarkers(disk) && !hasConflictMarkers(fe.text.toString())) { adoptResolved(relPath); return }
|
|
819
|
+
}
|
|
330
820
|
const docText = fe.text.toString()
|
|
331
821
|
if (docText === '' && !manifest.has(relPath)) {
|
|
332
822
|
// the relay has no copy of this path -> publish ours (a genuinely new file)
|
|
333
823
|
if (disk === null) return
|
|
334
824
|
fe.doc.transact(() => applyDiff(fe.text, disk))
|
|
825
|
+
stampProvenance(relPath, '', disk) // genesis: sign the file's first content (base = empty)
|
|
335
826
|
manifest.set(relPath, 1)
|
|
336
|
-
known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
|
|
827
|
+
known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk); shadow.set(relPath, disk)
|
|
337
828
|
try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
|
|
338
829
|
captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'created' }); snappedBase.add(relPath)
|
|
339
830
|
return
|
|
@@ -341,7 +832,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
341
832
|
const yt = fe.text
|
|
342
833
|
if (disk === null) { writeToDisk(relPath, docText); bases.set(relPath, docText); forkBases.set(relPath, docText); return }
|
|
343
834
|
if (disk === docText) {
|
|
344
|
-
known.add(relPath); bases.set(relPath, disk)
|
|
835
|
+
known.add(relPath); bases.set(relPath, disk); shadow.set(relPath, disk) // agreed state = capture baseline
|
|
345
836
|
// disk and doc already agree — a safe fork point, but only adopt it as
|
|
346
837
|
// THIS author's fork on first sight or our own local activity (a remote
|
|
347
838
|
// change landing on disk doesn't mean the local author has seen it).
|
|
@@ -349,6 +840,20 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
349
840
|
if (!snappedBase.has(relPath) && disk) { captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'baseline' }); snappedBase.add(relPath) }
|
|
350
841
|
return
|
|
351
842
|
}
|
|
843
|
+
// FIRST-CONTACT ADOPT (no common ancestor). We have never reconciled this file
|
|
844
|
+
// this session (no base history), yet it ALREADY exists in the room (non-empty
|
|
845
|
+
// docText) AND on local disk with DIFFERENT content. There is no shared base, so
|
|
846
|
+
// a 3-way merge would UNION the two copies — the duplicated-content bug seen when
|
|
847
|
+
// a client clones a repo and then joins a room that already has those files. A
|
|
848
|
+
// joining client adopts the ROOM's copy wholesale; its local copy is preserved as
|
|
849
|
+
// a restore point so nothing is lost and the user can revert if it was the good one.
|
|
850
|
+
if (!bases.has(relPath) && docText !== '' && disk !== docText) {
|
|
851
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy at join (room copy adopted)' })
|
|
852
|
+
writeToDisk(relPath, docText)
|
|
853
|
+
bases.set(relPath, docText); forkBases.set(relPath, docText)
|
|
854
|
+
if (!snappedBase.has(relPath)) { captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'adopted from room at join' }); snappedBase.add(relPath) }
|
|
855
|
+
return
|
|
856
|
+
}
|
|
352
857
|
const base = bases.has(relPath) ? bases.get(relPath) : disk
|
|
353
858
|
const fork = forkBases.has(relPath) ? forkBases.get(relPath) : base
|
|
354
859
|
if (origin === 'local') { noteCoEditing(relPath); markEditing(relPath) } // broadcast activity + warn if someone else is on this file
|
|
@@ -360,7 +865,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
360
865
|
res = { text: disk, conflict: false }
|
|
361
866
|
} else if (origin === 'local') {
|
|
362
867
|
noteIfRewrite(relPath, fork, disk)
|
|
363
|
-
//
|
|
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
|
|
364
873
|
// last saw), not the latest doc. If the doc gained lines since the fork
|
|
365
874
|
// that this write does NOT contain, the author was working from a stale
|
|
366
875
|
// copy — merging against the fork re-adds those lines (disjoint) or raises
|
|
@@ -368,8 +877,8 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
368
877
|
const theirsNew = changedRange(fork.split('\n'), docText.split('\n')).newLines.filter((l) => l.length)
|
|
369
878
|
const mineLines = new Set(disk.split('\n'))
|
|
370
879
|
const integrated = theirsNew.every((l) => mineLines.has(l)) // this write already contains the remote change
|
|
371
|
-
if (integrated) {
|
|
372
|
-
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
|
|
373
882
|
} else {
|
|
374
883
|
res = icrMerge3(fork, disk, docText, relPath)
|
|
375
884
|
reAdded = !res.conflict && res.text !== disk // we restored remote lines a stale write had dropped
|
|
@@ -386,6 +895,18 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
386
895
|
lastLocalEdit = relPath
|
|
387
896
|
}
|
|
388
897
|
fileDocs.get(relPath).doc.transact(() => applyDiff(yt, res.text))
|
|
898
|
+
// Sign this landed local change into the file's provenance ledger (I1). Stamp the
|
|
899
|
+
// author's TRUE intent — the state THEY edited from (fork) -> the content THEY wrote
|
|
900
|
+
// (disk) — NOT the live shared text, which under truly-concurrent edits is already
|
|
901
|
+
// fusing. Recording (fork, disk) keeps every concurrent same-line edit anchored to the
|
|
902
|
+
// SAME clean parent, so the fork stays detectable no matter how the char-CRDT fuses.
|
|
903
|
+
if (origin === 'local') {
|
|
904
|
+
// Gate ON: the author's INTENT was already captured at the source (captureLocal, top
|
|
905
|
+
// of this pass) — here we only advance the authority head to the landed content.
|
|
906
|
+
// Gate OFF (default): the proven landed-transition stamp, unchanged.
|
|
907
|
+
if (FORK_GATE) { if (res.text !== docText) attestHead(relPath, res.text, docText) }
|
|
908
|
+
else if (res.text !== docText) stampProvenance(relPath, docText, res.text)
|
|
909
|
+
}
|
|
389
910
|
if (res.text !== disk) writeToDisk(relPath, res.text)
|
|
390
911
|
known.add(relPath); bases.set(relPath, res.text)
|
|
391
912
|
if (origin === 'local') forkBases.set(relPath, res.text) // we authored this state; it's our new fork
|
|
@@ -421,6 +942,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
421
942
|
} else if (!res.icrWarning && icrWarned.has(relPath)) {
|
|
422
943
|
icrWarned.delete(relPath)
|
|
423
944
|
}
|
|
945
|
+
|
|
946
|
+
// Health gate on the live path: after the char-CRDT has converged, check whether the
|
|
947
|
+
// current content is a SILENT FORK (a fusion nobody authored). If so, record it (the
|
|
948
|
+
// record is rendered to disk locally — never written back into the shared text).
|
|
949
|
+
detectFork(relPath)
|
|
424
950
|
}
|
|
425
951
|
|
|
426
952
|
const fmtTime = () => new Date().toTimeString().slice(0, 8)
|
|
@@ -526,6 +1052,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
526
1052
|
const current = fe.text.toString()
|
|
527
1053
|
if (current === entry.content) return { ok: true, unchanged: true, file: relPath }
|
|
528
1054
|
captureSnapshot(relPath, current, by, { force: true, kind: 'restore', label: `before restore to ${entry.at}` })
|
|
1055
|
+
captureLocal(relPath) // record any pending authored edit before the restore overwrites it
|
|
529
1056
|
fe.doc.transact(() => applyDiff(fe.text, entry.content))
|
|
530
1057
|
writeToDisk(relPath, entry.content)
|
|
531
1058
|
bases.set(relPath, entry.content); forkBases.set(relPath, entry.content)
|
|
@@ -819,6 +1346,91 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
819
1346
|
return {
|
|
820
1347
|
doc,
|
|
821
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.
|
|
1366
|
+
setState: (key, val) => {
|
|
1367
|
+
const rules = swarmSchema.get(key)
|
|
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
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
swarmState.set(key, val)
|
|
1399
|
+
say(`STATE TRANSITION: [${key}] is now [${val}]`)
|
|
1400
|
+
},
|
|
1401
|
+
getState: () => {
|
|
1402
|
+
const s = {}
|
|
1403
|
+
for (const [k, v] of swarmState.entries()) s[k] = v
|
|
1404
|
+
return s
|
|
1405
|
+
},
|
|
1406
|
+
setSchema: (key, rules) => {
|
|
1407
|
+
swarmSchema.set(key, rules)
|
|
1408
|
+
say(`SCHEMA UPDATED: [${key}] rules set to ${JSON.stringify(rules)}`)
|
|
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
|
+
},
|
|
822
1434
|
say, // post a coordination message
|
|
823
1435
|
assign, // direct a task at a participant (needs approval if target AI has an owner)
|
|
824
1436
|
decide, // approve/deny a task (owner only, if set)
|
|
@@ -828,10 +1440,27 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
828
1440
|
isPaused, // is a participant currently paused?
|
|
829
1441
|
controls, // the live control map
|
|
830
1442
|
// --- hive coordination: claim a region BEFORE editing (collision prevention) ---
|
|
831
|
-
claim: (region, intent) =>
|
|
832
|
-
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) },
|
|
833
1445
|
senseClaim: (region) => coordinator.sense(String(region)), // the live claim on a region, or null
|
|
834
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
|
|
1451
|
+
// --- convergent substrate: provenance (I1) ---
|
|
1452
|
+
identity: () => identity ? { id: identity.id, name: identity.name, pk: identity.pk } : null, // never exposes the private key
|
|
1453
|
+
provenanceOf: (relPath) => { const e = fileDocs.get(relPath); return e && e.ledger ? e.ledger.toArray() : [] }, // signed receipts for a file
|
|
1454
|
+
headOf: (relPath) => { const e = fileDocs.get(relPath); return e && e.head ? e.head.get('cur') || null : null }, // the file's authoritative attested current content
|
|
1455
|
+
// Audit a file's ledger: verify EVERY receipt's signature + author identity, and
|
|
1456
|
+
// report which authors contributed. Returns { ok, count, verified, bad, authors }.
|
|
1457
|
+
verifyProvenanceOf: (relPath) => {
|
|
1458
|
+
const e = fileDocs.get(relPath)
|
|
1459
|
+
const rs = e && e.ledger ? e.ledger.toArray() : []
|
|
1460
|
+
let verified = 0; const bad = []; const authors = new Set()
|
|
1461
|
+
for (let i = 0; i < rs.length; i++) { const v = verifyReceipt(rs[i]); if (v.ok) { verified++; authors.add(rs[i].author) } else bad.push({ i, reason: v.reason }) }
|
|
1462
|
+
return { ok: bad.length === 0, count: rs.length, verified, bad, authors: [...authors] }
|
|
1463
|
+
},
|
|
835
1464
|
// --- rollback ---
|
|
836
1465
|
listHistory, // the restore-point timeline (newest first; filter by file/by)
|
|
837
1466
|
restore, // restore a file to a restore point by id (reversible)
|