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/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. This is how the hive plans and divides work by itself.
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
- fprovider.on('sync', (s) => { if (s) { e.synced = true; reconcile(relPath, 'remote') } }) // initial pull settled
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
- // THE FIX. Merge a local edit against the FORK POINT (what this author
868
+ // Check if this write is an intentional clean rewrite
869
+ const sc = summarizeChange(fork, disk)
870
+ const isCleanRewrite = sc.isRewrite && supports(relPath) && languageFor(relPath).parses(disk)
871
+
872
+ // Merge a local edit against the FORK POINT (what this author
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) => coordinator.acquire(String(region), String(intent || 'edit')), // true iff you hold it
832
- release: (region) => coordinator.release(String(region)),
1443
+ claim: (region, intent) => { const r = String(region), i = String(intent || 'edit'); myIntents.set(r, i); return coordinator.acquire(r, i) }, // true iff you hold it (my intent recorded either way — it's what my receipts sign)
1444
+ release: (region) => { const r = String(region); myIntents.delete(r); return coordinator.release(r) },
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)