hivecode-mcp 0.7.0 → 0.8.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/sync.js CHANGED
@@ -21,10 +21,11 @@ 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
28
29
 
29
30
  // Wire-format/protocol version. Bump when a change makes clients incompatible
30
31
  // (e.g. the v2 per-file-subdoc model). Broadcast via awareness so peers can warn
@@ -36,6 +37,7 @@ const PROTOCOL_VERSION = 2
36
37
  const ALWAYS_IGNORE = [
37
38
  '.git/', 'node_modules/',
38
39
  '.env', '.env.*', '*.pem', '*.key', '*.pfx', '*.p12', 'id_rsa', 'id_ed25519', '*.keystore',
40
+ '.hive-id.json', // this client's substrate signing identity (holds a PRIVATE key) — never sync it
39
41
  '*.log', '.DS_Store', 'Thumbs.db', '*.vsix',
40
42
  ]
41
43
 
@@ -45,12 +47,13 @@ const BOARD_FILE = 'HIVE_BOARD.md' // generated locally from `board`; never sync
45
47
  const CHAT_FILE = 'HIVE_CHAT.md' // generated locally from `chat`; the agents' conversation
46
48
  const TASKS_FILE = 'HIVE_TASKS.md' // generated locally from `tasks`; directed work + approvals
47
49
  const CONFIG_FILE = '.hive.json' // local rendezvous config (room id); not synced
50
+ const ID_FILE = '.hive-id.json' // this client's substrate signing identity (private); never synced
48
51
  const RULES_FILE = 'HIVE_RULES.md' // the law every participant follows; written into every room
49
52
  const MEMBERS_FILE = 'HIVE_MEMBERS.md' // live presence: who is in the room right now
50
53
  const AGENTS_FILE = 'HIVE_FOR_AGENTS.md' // how an AI agent opening this folder joins (via .hive.json) + behaves
51
54
  // Generated/coordination files are rendered locally from CRDT state — never
52
55
  // 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])
56
+ const SKIP = new Set([BOARD_FILE, CHAT_FILE, TASKS_FILE, CONFIG_FILE, ID_FILE, RULES_FILE, MEMBERS_FILE, AGENTS_FILE])
54
57
 
55
58
  // Written into every room folder on sync (like the rules). An AI agent that opens
56
59
  // this project reads it to learn it's a Hivecode room and how to JOIN — via the
@@ -139,8 +142,10 @@ another's work. The sync layer enforces the hard parts automatically; you do the
139
142
  refactor) that touches another participant's area.
140
143
 
141
144
  ## 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. This is how the hive plans and divides work by itself.
145
+ 11. AI -> AI is COORDINATION: if another agent hands you work (via hive_assign), it is auto-accepted
146
+ and you may proceed. If there is no Lead PM setting global state, use hive_assign and hive_say
147
+ to assign tasks directly to other agents to divide and conquer. This is how a leaderless hive
148
+ plans and divides work by itself.
144
149
  12. A HUMAN directing you is different. If a human who is NOT your owner assigns
145
150
  you a task, you do NOT act on it — it stays PENDING until YOUR OWNER approves
146
151
  ("do it or ignore?"). Your own owner's requests proceed. Act only on tasks
@@ -165,6 +170,11 @@ another's work. The sync layer enforces the hard parts automatically; you do the
165
170
  16. YOU triage: announce do-now vs after-current. Your OWNER can override anytime
166
171
  — if your owner says "do it now" or "skip that", that wins.
167
172
 
173
+ ## State-Machine Prompting (SMP)
174
+ 17. The Lead PM controls the [GLOBAL PROJECT STATE] via hive_set_state.
175
+ 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.
176
+ 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.
177
+
168
178
  Read → announce → patch → respect lanes → resolve conflicts → talk → triage pings → wait for approval.
169
179
  `
170
180
 
@@ -204,7 +214,275 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
204
214
  // Agents SENSE → FLOW → CLAIM before editing so they don't collide. No central controller;
205
215
  // claims evaporate via TTL so a stalled agent never deadlocks the hive.
206
216
  const claims = doc.getMap('claims')
217
+ const swarmState = doc.getMap('swarm_state')
218
+ const swarmSchema = doc.getMap('swarm_schema')
207
219
  const coordinator = makeCoordinator(claims, name, { ttl: 300000 }) // 5-min claim TTL
220
+
221
+ // --- convergent substrate: signed provenance (opt-in via HIVE_PROVENANCE) ------
222
+ // When on, this client loads (or mints) a persistent Ed25519 identity and SIGNS
223
+ // every change it lands, appending a verifiable receipt to each file's provenance
224
+ // ledger. This makes invariant I1 (provenance-verified) real end-to-end: every
225
+ // state the room ever holds is attributable to a cryptographic author + a declared
226
+ // intent. Everything here is wrapped so a failure can NEVER break sync — provenance
227
+ // is an added guarantee, not a new dependency of the live path.
228
+ const PROVENANCE = (process.env.HIVE_PROVENANCE || 'off').toLowerCase() !== 'off'
229
+ // The live SILENT-FORK GATE (detect + surface concurrent same-line edits) is EXPERIMENTAL
230
+ // and OFF by default. It reliably surfaces conflicts for realistic (2-3 agent, non-burst)
231
+ // concurrency, but robustly detecting forks needs a source-level capture of each authored
232
+ // edit BEFORE it merges — the fs.watch/disk-diff model contaminates the authored version,
233
+ // so detection can't be made both complete (catch every clash) and sound (never false-flag
234
+ // a disjoint edit). Opt in with HIVE_FORK_GATE=on. When off, sync behaves exactly as the
235
+ // proven core: provenance still signs every change; only the fork surfacing is disabled.
236
+ const FORK_GATE = PROVENANCE && (process.env.HIVE_FORK_GATE || 'off').toLowerCase() !== 'off'
237
+ let identity = null
238
+ if (PROVENANCE) {
239
+ const idPath = path.join(ROOT, ID_FILE)
240
+ 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 }
241
+ if (!identity) { try { identity = genIdentity(name); fs.writeFileSync(idPath, JSON.stringify(identity, null, 2)) } catch { identity = null } }
242
+ if (identity) log(`[${name}] substrate provenance ON — signing as ${identity.id.slice(0, 12)}…`)
243
+ }
244
+ // Record a signed receipt for a landed local change (base -> next) on a file's
245
+ // ledger. Intent is taken from this author's live claim on the region, so the
246
+ // WHY that the coordinator already knows is what gets cryptographically bound.
247
+ // Two concerns, deliberately decoupled:
248
+ // stampIntent -> LEDGER receipt: the author's INTENT — the state they edited FROM (base)
249
+ // -> the content they WROTE (next). With the fork gate on this comes from the SOURCE
250
+ // capture (captureLocal), so it is anchored to a clean base, not the fusing shared
251
+ // text — which is what keeps concurrent same-line edits fork-detectable.
252
+ // attestHead -> HEAD: content AUTHORITY — attests the ACTUAL landed (converged) content,
253
+ // so the relay's non-regression guard always judges real content, never partial intent.
254
+ function stampIntent(relPath, base, next) {
255
+ if (!PROVENANCE || !identity) return
256
+ const fe = fileDocs.get(relPath); if (!fe || !fe.ledger) return
257
+ // Idempotent: if THIS author already signed this exact (parent -> content) transition,
258
+ // don't duplicate it — the source capture and the edit path may both stamp the same change.
259
+ const pHash = contentHash(base == null ? '' : base), cHash0 = contentHash(next)
260
+ if (fe.ledger.toArray().some((r) => r && r.author === identity.id && r.parent === pHash && r.contentHash === cHash0)) return
261
+ try {
262
+ const c = coordinator.sense(relPath)
263
+ const ch = authorChange({ identity, filename: relPath, base: base == null ? '' : base, text: next, intent: (c && c.intent) || 'edit', at: Date.now() })
264
+ // `name` is display-only (NOT signed) — the verified identity is `author` (the key
265
+ // fingerprint). A UI shows both: the friendly name, and the cryptographic author it
266
+ // must match. Signature covers everything else, so tampering `name` can't fake trust.
267
+ const receipt = { ...ch.prov, name }
268
+ fe.doc.transact(() => {
269
+ fe.ledger.push([receipt]) // append INTENT to history (audit + fork detection)
270
+ // Preserve the FULL TEXT of both states, keyed by content hash — the raw material
271
+ // fork detection reconstructs pre-fusion versions from after the char-CRDT fuses them.
272
+ storeVersion(fe, base == null ? '' : base)
273
+ storeVersion(fe, next)
274
+ })
275
+ } catch { /* provenance must never break the sync path */ }
276
+ }
277
+ function attestHead(relPath, landed, base) {
278
+ if (!PROVENANCE || !identity || landed == null) return
279
+ const fe = fileDocs.get(relPath); if (!fe || !fe.head) return
280
+ try {
281
+ const cur = fe.head.get('cur')
282
+ if (cur && cur.hash === contentHash(landed)) return // head already attests this content
283
+ const c = coordinator.sense(relPath)
284
+ const at = Date.now()
285
+ const ch = authorChange({ identity, filename: relPath, base: base == null ? '' : base, text: landed, intent: (c && c.intent) || 'edit', at })
286
+ fe.doc.transact(() => {
287
+ fe.head.set('cur', { text: landed, hash: ch.prov.contentHash, at, by: name, receipt: { ...ch.prov, name } })
288
+ storeVersion(fe, landed)
289
+ })
290
+ } catch { /* provenance must never break the sync path */ }
291
+ }
292
+ // Compatibility wrapper: record intent AND attest the landed content (defaults to next).
293
+ function stampProvenance(relPath, base, next, landed) {
294
+ stampIntent(relPath, base, next)
295
+ attestHead(relPath, landed == null ? next : landed, base)
296
+ }
297
+ // Record text by its content hash, bounded: drop any stored version no longer
298
+ // referenced by a receipt (as a parent or a result) so the map can't grow forever.
299
+ function storeVersion(fe, text) {
300
+ if (!fe || !fe.versions || text == null) return
301
+ fe.versions.set(contentHash(text), text)
302
+ if (fe.versions.size > 64) {
303
+ const live = new Set()
304
+ for (const r of fe.ledger.toArray()) { if (r && r.parent) live.add(r.parent); if (r && r.contentHash) live.add(r.contentHash) }
305
+ for (const h of [...fe.versions.keys()]) if (!live.has(h)) fe.versions.delete(h)
306
+ }
307
+ }
308
+ // SOURCE-LEVEL EDIT CAPTURE — the subsystem that makes fork detection sound.
309
+ // A room folder has exactly TWO writers: this engine (every write it makes is recorded in
310
+ // `shadow`) and the local author (their editor/agent — everything else). So any divergence
311
+ // of disk from shadow IS an authored edit, and (shadow -> disk) is its true, uncontaminated
312
+ // (base -> text) pair — captured and signed BEFORE any merge or conflict render can
313
+ // overwrite the file. This is what after-the-fact disk-diffing could never provide: the
314
+ // author's version unpolluted by concurrent merges. It closes the capture race: the old
315
+ // window was the whole watcher debounce; now capture runs at the entry of every pass that
316
+ // could clobber, so an authored edit enters the provenance DAG before anything overwrites
317
+ // it. Gate-on only; the proven default path is untouched.
318
+ function captureLocal(relPath) {
319
+ if (!FORK_GATE || !identity) return false
320
+ const fe = fileDocs.get(relPath); if (!fe || !fe.synced || !fe.ledger) return false
321
+ if (SKIP.has(relPath) || isIgnored(relPath)) return false
322
+ let disk = null; try { disk = fs.readFileSync(path.join(ROOT, relPath), 'utf8') } catch { return false }
323
+ const base = shadow.get(relPath)
324
+ if (base == null || disk === base) return false // no baseline yet, or nothing new on disk
325
+ if (hasConflictMarkers(disk)) return false // half-edited conflict block — not an authored state
326
+ stampIntent(relPath, base, disk) // into the provenance DAG NOW — clobber-proof
327
+ shadow.set(relPath, disk) // the captured state is the new baseline
328
+ return true
329
+ }
330
+ // Build ONE clean N-way conflict block from a common parent and N divergent versions:
331
+ // strip the lines they (and the parent) all share as a prefix/suffix, then stack each
332
+ // version's differing middle between markers. Deterministic (caller sorts versions), so
333
+ // every peer produces byte-identical output. Returns null if nothing distinct remains.
334
+ function nwayConflict(parentText, versions) {
335
+ const P = parentText.split('\n')
336
+ const Vs = versions.map((v) => v.text.split('\n'))
337
+ const minLen = Math.min(P.length, ...Vs.map((v) => v.length))
338
+ let pre = 0
339
+ while (pre < minLen && Vs.every((v) => v[pre] === P[pre])) pre++
340
+ let suf = 0
341
+ while (suf < minLen - pre && Vs.every((v) => v[v.length - 1 - suf] === P[P.length - 1 - suf])) suf++
342
+ const out = [...P.slice(0, pre)]
343
+ let any = false
344
+ versions.forEach((v, i) => {
345
+ const mid = Vs[i].slice(pre, Vs[i].length - suf)
346
+ if (mid.length) any = true
347
+ out.push(`${i === 0 ? '<<<<<<<' : '======='} ${v.name} (${String(v.hash).slice(0, 7)})`)
348
+ out.push(...mid)
349
+ })
350
+ // Closing marker MUST carry a trailing label — core.js hasConflictMarkers matches
351
+ // /^>>>>>>> /m (note the space), so a bare ">>>>>>>" would not register as a conflict
352
+ // and the sync layer would treat the block as clean content.
353
+ out.push('>>>>>>> (resolve: keep one, delete the markers)')
354
+ out.push(...P.slice(P.length - suf))
355
+ return any ? out.join('\n') : null
356
+ }
357
+ // SILENT-FORK DETECTION — the health gate, done RIGHT (local-render model).
358
+ //
359
+ // The char-CRDT (Y.Text) always converges, even when it shouldn't: two agents editing
360
+ // the SAME line fuse into valid-but-unauthored code ("return 2" + "return 3" -> "return
361
+ // 32") that no layer flags. Nobody signs that fused byte-string, yet BOTH real versions
362
+ // were signed against the SAME parent. So the provenance ledger still holds the truth
363
+ // the char-merge destroyed.
364
+ //
365
+ // The KEY design choice: we do NOT write conflict markers back into the shared Y.Text
366
+ // (that just makes the markers themselves fuse — the old bug). Instead a fork is recorded
367
+ // as a deterministic, converged RECORD (a per-file LWW register, computed purely from the
368
+ // ledger so every peer derives the identical value), and each peer renders the markers to
369
+ // its OWN disk. Nothing concurrent is written to the shared text, so nothing can tangle.
370
+ // Y.Text is frozen at the fused value and simply ignored until a human resolves.
371
+
372
+ // Compute the live fork purely from the ledger. Same input -> same output on every peer,
373
+ // so setting the record is idempotent and needs no applier election. Returns
374
+ // { parent, base, versions:[{hash,name,author}] } or null.
375
+ function computeFork(fe) {
376
+ const receipts = fe.ledger.toArray()
377
+ if (!receipts.length) return null
378
+ const builtOn = new Set(receipts.map((r) => r && r.parent).filter(Boolean))
379
+ const byParent = new Map()
380
+ for (const r of receipts) {
381
+ if (!r || !r.parent || !r.contentHash) continue
382
+ if (!byParent.has(r.parent)) byParent.set(r.parent, new Map())
383
+ byParent.get(r.parent).set(r.contentHash, r) // dedupe divergent children by content
384
+ }
385
+ for (const [parent, kids] of byParent) {
386
+ if (kids.size < 2) continue
387
+ const base = fe.versions.get(parent)
388
+ if (base == null) continue
389
+ let arr = [...kids.values()]
390
+ .map((r) => ({ r, text: fe.versions.get(r.contentHash) }))
391
+ .filter((x) => x.text != null && !fe.forkmark.has(x.r.contentHash)) // skip versions already folded into a resolved conflict
392
+ const tips = arr.filter((x) => !builtOn.has(x.r.contentHash)) // prefer live heads
393
+ if (tips.length >= 2) arr = tips
394
+ if (arr.length < 2) continue
395
+ arr.sort((a, b) => (a.r.contentHash < b.r.contentHash ? -1 : 1)) // deterministic order across peers
396
+ // Discriminator: a REAL same-region fork, or disjoint edits the char-merge combined
397
+ // fine? If no pair actually conflicts on a 3-way against the base, leave it.
398
+ let real = false
399
+ for (let i = 0; i < arr.length && !real; i++)
400
+ for (let j = i + 1; j < arr.length && !real; j++) {
401
+ const m = merge3(base, arr[i].text, arr[j].text)
402
+ if (m.conflict || hasConflictMarkers(m.text)) real = true
403
+ }
404
+ if (!real) continue
405
+ return { parent, base, versions: arr.map((x) => ({ hash: x.r.contentHash, name: x.r.name, author: x.r.author })) }
406
+ }
407
+ return null
408
+ }
409
+ // The deterministic conflict block for a record (byte-identical on every peer).
410
+ function forkBlock(fe, rec) {
411
+ const versions = rec.versions.map((v) => ({ text: fe.versions.get(v.hash), name: v.name, hash: v.hash })).filter((v) => v.text != null)
412
+ if (versions.length < 2) return null
413
+ return nwayConflict(rec.base, versions)
414
+ }
415
+ // Read the ACTIVE fork from the conflict map. The version set is stored as a UNION of
416
+ // per-hash keys ('v:'+hash) that MERGE across peers (not a single LWW register), so a
417
+ // late-arriving version can never be lost to a last-writer-wins race — the set only grows
418
+ // and converges to the full set on every peer. Returns { parent, base, versions } or null.
419
+ function forkRecord(fe) {
420
+ if (!fe || !fe.conflict) return null
421
+ const meta = fe.conflict.get('meta'); if (!meta) return null
422
+ const versions = []
423
+ for (const key of fe.conflict.keys()) {
424
+ if (!key.startsWith('v:')) continue
425
+ const v = fe.conflict.get(key)
426
+ versions.push({ hash: key.slice(2), name: v.name, author: v.author })
427
+ }
428
+ if (versions.length < 2) return null
429
+ versions.sort((a, b) => (a.hash < b.hash ? -1 : 1)) // deterministic order across peers
430
+ return { parent: meta.parent, base: meta.base, versions }
431
+ }
432
+ // Render the active conflict to THIS peer's disk (local only — never Y.Text).
433
+ function renderConflict(relPath) {
434
+ const fe = fileDocs.get(relPath); if (!fe || !fe.conflict) return
435
+ // Before we overwrite this file with the conflict block, capture any local edit sitting
436
+ // on disk that hasn't been stamped yet — otherwise rendering would silently destroy it.
437
+ if (captureLocal(relPath)) { detectFork(relPath); return } // it grew the fork; detectFork re-renders with it included
438
+ const rec = forkRecord(fe); if (!rec) return
439
+ const block = forkBlock(fe, rec); if (!block) return
440
+ let disk = null; try { disk = fs.readFileSync(path.join(ROOT, relPath), 'utf8') } catch { }
441
+ forkBases.set(relPath, block) // this peer has now SEEN the markers — a later clean edit from here is a resolution
442
+ if (disk === block) return
443
+ writeToDisk(relPath, block)
444
+ if (!conflicted.has(relPath)) {
445
+ conflicted.add(relPath)
446
+ const who = rec.versions.map((v) => v.name).join(', ')
447
+ log(`[${name}] ⚠ SILENT FORK in ${relPath} — ${who} changed the same region at once; provenance caught it. Surfaced ALL ${rec.versions.length} versions with <<<<<<< markers (rendered locally, not into the shared text).`)
448
+ try { say(`⚠ SILENT FORK in ${relPath}: ${who} changed the same region concurrently. Surfaced ALL ${rec.versions.length} versions with <<<<<<< markers. Resolve before continuing.`) } catch { }
449
+ }
450
+ }
451
+ // A resolution landed elsewhere: the shared text is clean again. Adopt it to disk.
452
+ function adoptResolved(relPath) {
453
+ const fe = fileDocs.get(relPath); if (!fe) return
454
+ const dt = fe.text.toString()
455
+ if (hasConflictMarkers(dt)) return
456
+ captureLocal(relPath) // an authored edit may be sitting on disk — record it before adopting over it
457
+ writeToDisk(relPath, dt)
458
+ bases.set(relPath, dt); forkBases.set(relPath, dt)
459
+ conflicted.delete(relPath)
460
+ }
461
+ // Detect a live fork and record it. The version set is a CRDT UNION (per-hash keys), so
462
+ // this is monotonic + convergent: every peer contributes what it sees and the set grows
463
+ // to the full fork on all peers regardless of arrival order. Does NOT touch Y.Text.
464
+ function detectFork(relPath) {
465
+ if (!FORK_GATE) return
466
+ const fe = fileDocs.get(relPath)
467
+ if (!fe || !fe.ledger || !fe.versions || !fe.conflict || !fe.synced) return
468
+ const rec = computeFork(fe)
469
+ if (fe.conflict.has('meta')) {
470
+ // Already in conflict — union in any newly-seen versions (never overwrites; only adds).
471
+ 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 }) })
472
+ renderConflict(relPath)
473
+ return
474
+ }
475
+ const cur = fe.text.toString()
476
+ // Current content is itself a signed/authored state (one side won cleanly, or a human
477
+ // already resolved) — not a silent fusion.
478
+ if (fe.ledger.toArray().some((r) => r && r.contentHash === contentHash(cur))) return
479
+ if (!rec) return
480
+ fe.doc.transact(() => {
481
+ fe.conflict.set('meta', { parent: rec.parent, base: rec.base })
482
+ for (const v of rec.versions) fe.conflict.set('v:' + v.hash, { name: v.name, author: v.author })
483
+ })
484
+ renderConflict(relPath)
485
+ }
208
486
  // disableBc: do NOT sync peer-to-peer over BroadcastChannel. The relay must be
209
487
  // the ONLY path between clients — otherwise two clients on the same machine would
210
488
  // sync directly and bypass the relay's access control (auth, path scope, read-only).
@@ -229,11 +507,38 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
229
507
  // `undo` is a per-file UndoManager tracking THIS client's local edits, so a
230
508
  // human/agent can undo their own CRDT changes (Level-0 rollback).
231
509
  const snap = fdoc.getMap('snap')
510
+ // The file's provenance LEDGER: an append-only list of signed receipts, one per
511
+ // landed change. Lives in the file's own doc, so it inherits the file's access
512
+ // control and travels with it. Any peer can verifyReceipt() every entry.
513
+ const ledger = fdoc.getArray('ledger')
514
+ // The authoritative HEAD: a single signed pointer at the file's CURRENT content
515
+ // (key 'cur' -> { text, hash, at, by, receipt }). Content authority = the relay
516
+ // guarantees this is always a verified, non-regressing attestation of the content.
517
+ const head = fdoc.getMap('head')
518
+ // versions: contentHash -> full text, for every state any author edited from or
519
+ // produced. The char-CRDT keeps only the fused current bytes; this keeps the
520
+ // pre-fusion originals so a silent fork can be reconstructed and surfaced.
521
+ const versions = fdoc.getMap('versions')
522
+ // forkmark: contentHash -> 1 for every version already folded into a surfaced
523
+ // conflict. Synced, so once ANY peer surfaces a fork, those versions never trigger
524
+ // again — the conflict stays put until a human resolves it, and a resolution can't
525
+ // be re-conflicted by a peer whose ledger lags its content (the re-fork race).
526
+ const forkmark = fdoc.getMap('forkmark')
527
+ // conflict: the per-file LWW fork RECORD (key 'cur' -> { parent, base, versions }).
528
+ // Deterministic (computed from the ledger), converged across peers, and rendered to
529
+ // each peer's DISK — never into the shared text — so conflict markers can't fuse.
530
+ const conflict = fdoc.getMap('conflict')
232
531
  const undo = new Y.UndoManager(text, { captureTimeout: 350 }) // default tracks local-origin txns
233
- e = { doc: fdoc, provider: fprovider, text, snap, undo, synced: false }
532
+ e = { doc: fdoc, provider: fprovider, text, snap, ledger, head, versions, forkmark, conflict, undo, synced: false }
234
533
  fileDocs.set(relPath, e)
235
534
  text.observe((_ev, txn) => { if (!txn.local) reconcile(relPath, 'remote') }) // remote content edits
236
- fprovider.on('sync', (s) => { if (s) { e.synced = true; reconcile(relPath, 'remote') } }) // initial pull settled
535
+ // A divergent receipt can arrive slightly after (or before) the fused content — so
536
+ // re-run the fork check whenever the provenance ledger changes too, not only on edits.
537
+ ledger.observe((_ev, txn) => { if (!txn.local) detectFork(relPath) })
538
+ // The fork record arrived/changed from a peer: render it locally, or — if it was
539
+ // cleared (resolved elsewhere) — adopt the now-clean shared text to disk.
540
+ conflict.observe((_ev, txn) => { if (txn.local) return; if (e.conflict.has('meta')) renderConflict(relPath); else adoptResolved(relPath) })
541
+ fprovider.on('sync', (s) => { if (s) { e.synced = true; reconcile(relPath, 'remote'); detectFork(relPath) } }) // initial pull settled
237
542
  return e
238
543
  }
239
544
  function closeFile(relPath) {
@@ -264,6 +569,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
264
569
 
265
570
  const known = new Set()
266
571
  const mtimes = new Map()
572
+ const shadow = new Map() // relPath -> exact bytes THIS ENGINE last wrote/captured (source-level edit capture)
267
573
  const bases = new Map() // last synced content per file (advances on every reconcile)
268
574
  const forkBases = new Map() // FORK POINT: what THIS author last saw/authored per file.
269
575
  // forkBases advances only on local authorship / first adoption — NOT when a
@@ -297,6 +603,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
297
603
  const full = path.join(ROOT, relPath)
298
604
  fs.mkdirSync(path.dirname(full), { recursive: true })
299
605
  fs.writeFileSync(full, content)
606
+ shadow.set(relPath, content) // record what THE ENGINE wrote — any later disk divergence is an authored edit
300
607
  known.add(relPath)
301
608
  try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
302
609
  }
@@ -327,13 +634,63 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
327
634
  let fe = fileDocs.get(relPath)
328
635
  if (!fe) { if (disk === null) return; openFile(relPath); return }
329
636
  if (!fe.synced) return
637
+ // SOURCE CAPTURE (fork gate): before ANYTHING in this pass can merge over or render into
638
+ // this file, record any authored edit sitting on disk. The edit enters the provenance
639
+ // DAG first; whatever happens to the disk afterwards can no longer lose it.
640
+ if (FORK_GATE) captureLocal(relPath)
641
+ // --- provenance conflict state machine (local-render model) ---
642
+ // While a file is in a surfaced fork, its markers live ONLY on disk; the shared
643
+ // Y.Text is left at the fused value. This is what avoids the marker-fusion the old
644
+ // in-CRDT approach suffered — nothing conflict-related is ever written to Y.Text.
645
+ if (FORK_GATE && fe.conflict) {
646
+ const rec = forkRecord(fe)
647
+ if (rec) {
648
+ const block = forkBlock(fe, rec)
649
+ if (origin === 'local') {
650
+ if (disk === block) return // our own rendered conflict — ignore
651
+ const fork0 = forkBases.get(relPath)
652
+ // Did THIS author edit FROM the conflict block (i.e. they saw the markers)? Only
653
+ // then is a clean edit a RESOLUTION. A clean edit from a NON-marked base is just a
654
+ // concurrent authored edit that happened during the fork — it must NOT be mistaken
655
+ // for a resolution, or that agent's work silently replaces everyone's conflict.
656
+ const sawMarkers = fork0 === block || hasConflictMarkers(fork0 || '')
657
+ if (sawMarkers && disk !== null && !hasConflictMarkers(disk)) {
658
+ // RESOLUTION: a human replaced the markers with clean code. Publish it to the
659
+ // shared text, fold the fork versions, and clear the record for everyone.
660
+ fe.doc.transact(() => {
661
+ applyDiff(fe.text, disk)
662
+ for (const v of rec.versions) fe.forkmark.set(v.hash, 1)
663
+ for (const key of [...fe.conflict.keys()]) fe.conflict.delete(key)
664
+ })
665
+ stampProvenance(relPath, block || '', disk)
666
+ conflicted.delete(relPath)
667
+ known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
668
+ try { say(`✓ conflict in ${relPath} resolved by ${name}.`) } catch { }
669
+ return
670
+ }
671
+ if (!sawMarkers && disk !== null && disk !== fork0) {
672
+ // A concurrent authored edit landed WHILE the fork was open (this agent never saw
673
+ // the markers). The source capture at the top of this pass already stamped it, so
674
+ // it is in the fork's version set — re-detect so the record grows to include it.
675
+ detectFork(relPath)
676
+ return
677
+ }
678
+ renderConflict(relPath); return // still conflicted (or partially edited) — re-assert canonical block
679
+ }
680
+ renderConflict(relPath); return // remote churn while in conflict — keep our local render
681
+ }
682
+ // No active record, but our disk still shows stale markers while the shared text is
683
+ // clean again -> a resolution landed elsewhere; adopt it (order-independent w/ observer).
684
+ if (disk !== null && hasConflictMarkers(disk) && !hasConflictMarkers(fe.text.toString())) { adoptResolved(relPath); return }
685
+ }
330
686
  const docText = fe.text.toString()
331
687
  if (docText === '' && !manifest.has(relPath)) {
332
688
  // the relay has no copy of this path -> publish ours (a genuinely new file)
333
689
  if (disk === null) return
334
690
  fe.doc.transact(() => applyDiff(fe.text, disk))
691
+ stampProvenance(relPath, '', disk) // genesis: sign the file's first content (base = empty)
335
692
  manifest.set(relPath, 1)
336
- known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
693
+ known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk); shadow.set(relPath, disk)
337
694
  try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
338
695
  captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'created' }); snappedBase.add(relPath)
339
696
  return
@@ -341,7 +698,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
341
698
  const yt = fe.text
342
699
  if (disk === null) { writeToDisk(relPath, docText); bases.set(relPath, docText); forkBases.set(relPath, docText); return }
343
700
  if (disk === docText) {
344
- known.add(relPath); bases.set(relPath, disk)
701
+ known.add(relPath); bases.set(relPath, disk); shadow.set(relPath, disk) // agreed state = capture baseline
345
702
  // disk and doc already agree — a safe fork point, but only adopt it as
346
703
  // THIS author's fork on first sight or our own local activity (a remote
347
704
  // change landing on disk doesn't mean the local author has seen it).
@@ -349,6 +706,20 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
349
706
  if (!snappedBase.has(relPath) && disk) { captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'baseline' }); snappedBase.add(relPath) }
350
707
  return
351
708
  }
709
+ // FIRST-CONTACT ADOPT (no common ancestor). We have never reconciled this file
710
+ // this session (no base history), yet it ALREADY exists in the room (non-empty
711
+ // docText) AND on local disk with DIFFERENT content. There is no shared base, so
712
+ // a 3-way merge would UNION the two copies — the duplicated-content bug seen when
713
+ // a client clones a repo and then joins a room that already has those files. A
714
+ // joining client adopts the ROOM's copy wholesale; its local copy is preserved as
715
+ // a restore point so nothing is lost and the user can revert if it was the good one.
716
+ if (!bases.has(relPath) && docText !== '' && disk !== docText) {
717
+ captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy at join (room copy adopted)' })
718
+ writeToDisk(relPath, docText)
719
+ bases.set(relPath, docText); forkBases.set(relPath, docText)
720
+ if (!snappedBase.has(relPath)) { captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'adopted from room at join' }); snappedBase.add(relPath) }
721
+ return
722
+ }
352
723
  const base = bases.has(relPath) ? bases.get(relPath) : disk
353
724
  const fork = forkBases.has(relPath) ? forkBases.get(relPath) : base
354
725
  if (origin === 'local') { noteCoEditing(relPath); markEditing(relPath) } // broadcast activity + warn if someone else is on this file
@@ -386,6 +757,18 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
386
757
  lastLocalEdit = relPath
387
758
  }
388
759
  fileDocs.get(relPath).doc.transact(() => applyDiff(yt, res.text))
760
+ // Sign this landed local change into the file's provenance ledger (I1). Stamp the
761
+ // author's TRUE intent — the state THEY edited from (fork) -> the content THEY wrote
762
+ // (disk) — NOT the live shared text, which under truly-concurrent edits is already
763
+ // fusing. Recording (fork, disk) keeps every concurrent same-line edit anchored to the
764
+ // SAME clean parent, so the fork stays detectable no matter how the char-CRDT fuses.
765
+ if (origin === 'local') {
766
+ // Gate ON: the author's INTENT was already captured at the source (captureLocal, top
767
+ // of this pass) — here we only advance the authority head to the landed content.
768
+ // Gate OFF (default): the proven landed-transition stamp, unchanged.
769
+ if (FORK_GATE) { if (res.text !== docText) attestHead(relPath, res.text, docText) }
770
+ else if (res.text !== docText) stampProvenance(relPath, docText, res.text)
771
+ }
389
772
  if (res.text !== disk) writeToDisk(relPath, res.text)
390
773
  known.add(relPath); bases.set(relPath, res.text)
391
774
  if (origin === 'local') forkBases.set(relPath, res.text) // we authored this state; it's our new fork
@@ -421,6 +804,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
421
804
  } else if (!res.icrWarning && icrWarned.has(relPath)) {
422
805
  icrWarned.delete(relPath)
423
806
  }
807
+
808
+ // Health gate on the live path: after the char-CRDT has converged, check whether the
809
+ // current content is a SILENT FORK (a fusion nobody authored). If so, record it (the
810
+ // record is rendered to disk locally — never written back into the shared text).
811
+ detectFork(relPath)
424
812
  }
425
813
 
426
814
  const fmtTime = () => new Date().toTimeString().slice(0, 8)
@@ -526,6 +914,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
526
914
  const current = fe.text.toString()
527
915
  if (current === entry.content) return { ok: true, unchanged: true, file: relPath }
528
916
  captureSnapshot(relPath, current, by, { force: true, kind: 'restore', label: `before restore to ${entry.at}` })
917
+ captureLocal(relPath) // record any pending authored edit before the restore overwrites it
529
918
  fe.doc.transact(() => applyDiff(fe.text, entry.content))
530
919
  writeToDisk(relPath, entry.content)
531
920
  bases.set(relPath, entry.content); forkBases.set(relPath, entry.content)
@@ -819,6 +1208,23 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
819
1208
  return {
820
1209
  doc,
821
1210
  provider,
1211
+ setState: (key, val) => {
1212
+ const rules = swarmSchema.get(key)
1213
+ if (rules && rules.enum && !rules.enum.includes(val)) {
1214
+ throw new Error(`[Schema Error] Invalid state for [${key}]. Allowed values: ${rules.enum.join(', ')}`);
1215
+ }
1216
+ swarmState.set(key, val)
1217
+ say(`STATE TRANSITION: [${key}] is now [${val}]`)
1218
+ },
1219
+ getState: () => {
1220
+ const s = {}
1221
+ for (const [k, v] of swarmState.entries()) s[k] = v
1222
+ return s
1223
+ },
1224
+ setSchema: (key, rules) => {
1225
+ swarmSchema.set(key, rules)
1226
+ say(`SCHEMA UPDATED: [${key}] rules set to ${JSON.stringify(rules)}`)
1227
+ },
822
1228
  say, // post a coordination message
823
1229
  assign, // direct a task at a participant (needs approval if target AI has an owner)
824
1230
  decide, // approve/deny a task (owner only, if set)
@@ -832,6 +1238,19 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
832
1238
  release: (region) => coordinator.release(String(region)),
833
1239
  senseClaim: (region) => coordinator.sense(String(region)), // the live claim on a region, or null
834
1240
  claimsBoard: () => coordinator.board(), // everything currently claimed
1241
+ // --- convergent substrate: provenance (I1) ---
1242
+ identity: () => identity ? { id: identity.id, name: identity.name, pk: identity.pk } : null, // never exposes the private key
1243
+ provenanceOf: (relPath) => { const e = fileDocs.get(relPath); return e && e.ledger ? e.ledger.toArray() : [] }, // signed receipts for a file
1244
+ headOf: (relPath) => { const e = fileDocs.get(relPath); return e && e.head ? e.head.get('cur') || null : null }, // the file's authoritative attested current content
1245
+ // Audit a file's ledger: verify EVERY receipt's signature + author identity, and
1246
+ // report which authors contributed. Returns { ok, count, verified, bad, authors }.
1247
+ verifyProvenanceOf: (relPath) => {
1248
+ const e = fileDocs.get(relPath)
1249
+ const rs = e && e.ledger ? e.ledger.toArray() : []
1250
+ let verified = 0; const bad = []; const authors = new Set()
1251
+ 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 }) }
1252
+ return { ok: bad.length === 0, count: rs.length, verified, bad, authors: [...authors] }
1253
+ },
835
1254
  // --- rollback ---
836
1255
  listHistory, // the restore-point timeline (newest first; filter by file/by)
837
1256
  restore, // restore a file to a restore point by id (reversible)