hivecode-mcp 0.6.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/README.md +68 -137
- package/hive-coord.js +72 -0
- package/hive-mcp.js +85 -10
- package/icr-merge.js +58 -0
- package/icr.js +272 -0
- package/lang-brace.js +174 -0
- package/lang-js.js +418 -0
- package/lang-python.js +149 -0
- package/package.json +18 -5
- package/substrate.js +252 -0
- package/sync.js +711 -19
- package/token.js +20 -0
package/sync.js
CHANGED
|
@@ -21,8 +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,
|
|
25
|
-
import {
|
|
24
|
+
import { applyDiff, summarizeChange, changedRange, hasConflictMarkers, merge3 } from './core.js'
|
|
25
|
+
import { icrMerge3 } from './icr-merge.js' // structure/intent-aware merge; falls back to line merge3
|
|
26
|
+
import { makeCoordinator } from './hive-coord.js' // decentralized claim layer (collision PREVENTION)
|
|
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
|
|
26
29
|
|
|
27
30
|
// Wire-format/protocol version. Bump when a change makes clients incompatible
|
|
28
31
|
// (e.g. the v2 per-file-subdoc model). Broadcast via awareness so peers can warn
|
|
@@ -34,6 +37,7 @@ const PROTOCOL_VERSION = 2
|
|
|
34
37
|
const ALWAYS_IGNORE = [
|
|
35
38
|
'.git/', 'node_modules/',
|
|
36
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
|
|
37
41
|
'*.log', '.DS_Store', 'Thumbs.db', '*.vsix',
|
|
38
42
|
]
|
|
39
43
|
|
|
@@ -43,11 +47,49 @@ const BOARD_FILE = 'HIVE_BOARD.md' // generated locally from `board`; never sync
|
|
|
43
47
|
const CHAT_FILE = 'HIVE_CHAT.md' // generated locally from `chat`; the agents' conversation
|
|
44
48
|
const TASKS_FILE = 'HIVE_TASKS.md' // generated locally from `tasks`; directed work + approvals
|
|
45
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
|
|
46
51
|
const RULES_FILE = 'HIVE_RULES.md' // the law every participant follows; written into every room
|
|
47
52
|
const MEMBERS_FILE = 'HIVE_MEMBERS.md' // live presence: who is in the room right now
|
|
53
|
+
const AGENTS_FILE = 'HIVE_FOR_AGENTS.md' // how an AI agent opening this folder joins (via .hive.json) + behaves
|
|
48
54
|
// Generated/coordination files are rendered locally from CRDT state — never
|
|
49
55
|
// synced as ordinary files (that would cause echo loops / conflicts).
|
|
50
|
-
const SKIP = new Set([BOARD_FILE, CHAT_FILE, TASKS_FILE, CONFIG_FILE, RULES_FILE, MEMBERS_FILE])
|
|
56
|
+
const SKIP = new Set([BOARD_FILE, CHAT_FILE, TASKS_FILE, CONFIG_FILE, ID_FILE, RULES_FILE, MEMBERS_FILE, AGENTS_FILE])
|
|
57
|
+
|
|
58
|
+
// Written into every room folder on sync (like the rules). An AI agent that opens
|
|
59
|
+
// this project reads it to learn it's a Hivecode room and how to JOIN — via the
|
|
60
|
+
// .hive.json already in the folder, so no human has to paste a link.
|
|
61
|
+
const AGENT_MANUAL_TEXT = `# Hivecode — for AI agents working in this folder
|
|
62
|
+
|
|
63
|
+
This project is part of a live Hivecode room: humans and AI agents editing the same
|
|
64
|
+
codebase together, in real time, with no git push/pull. If you are an AI assistant
|
|
65
|
+
asked to collaborate here, follow this.
|
|
66
|
+
|
|
67
|
+
## Join the room
|
|
68
|
+
1. If you do NOT have a \`hive_join\` tool and you can run terminal commands, register
|
|
69
|
+
the Hivecode MCP server, then reload so the tools load:
|
|
70
|
+
claude mcp add hivecode -- npx -y hivecode-mcp
|
|
71
|
+
2. Call: hive_join({ dir: ".", name: "<your name>", owner: "<the human you work for>" })
|
|
72
|
+
The room, relay, and token are already saved in .hive.json in this folder, so you
|
|
73
|
+
do NOT need a link — hive_join reads them automatically.
|
|
74
|
+
3. Read the HIVE_RULES it returns, and follow them.
|
|
75
|
+
|
|
76
|
+
## Work the loop
|
|
77
|
+
- hive_read_chat() and hive_read_board() BEFORE editing — see what others are doing.
|
|
78
|
+
- hive_claim("path/to/file") BEFORE you edit a file. If you get it, edit then
|
|
79
|
+
hive_release it. If you DON'T, someone else holds it — pick other work
|
|
80
|
+
(hive_claims() shows what's open). This is how agents avoid colliding.
|
|
81
|
+
- hive_say("taking X: doing Y") to announce intent before you edit.
|
|
82
|
+
- Edit ONLY inside the folders you were granted. Edits merge live; rewrites are logged.
|
|
83
|
+
- Block on hive_wait(); when it returns approved work, do it, then hive_complete(id).
|
|
84
|
+
- A task aimed at you stays PENDING until your owner approves it — act only on 'accepted'.
|
|
85
|
+
|
|
86
|
+
## Rules of the road
|
|
87
|
+
- Stay in your lane — never edit folders you weren't granted.
|
|
88
|
+
- Announce before editing; coordinate in chat; ask before anything destructive in others' areas.
|
|
89
|
+
- If a human PAUSES you, finish your current step and stop until resumed.
|
|
90
|
+
- Resolve <<<<<<< conflict markers properly — never blindly overwrite.
|
|
91
|
+
- A human can ROLL BACK your changes at any time; that's normal — re-read and continue.
|
|
92
|
+
`
|
|
51
93
|
|
|
52
94
|
// The hive's law. Auto-written into every room folder so it is ALWAYS present —
|
|
53
95
|
// no setup, no relying on an agent to remember it. The sync layer enforces the
|
|
@@ -70,6 +112,15 @@ another's work. The sync layer enforces the hard parts automatically; you do the
|
|
|
70
112
|
2. Read HIVE_BOARD.md — which files were just rewritten (and what was touched).
|
|
71
113
|
3. If a file you plan to edit appears there, RE-READ it before changing it.
|
|
72
114
|
|
|
115
|
+
## CLAIM before you edit (this is how the hive avoids collisions)
|
|
116
|
+
- Before editing a file, CLAIM it: call the hive_claim tool with the file path
|
|
117
|
+
(and a short intent). This is the most important coordination step.
|
|
118
|
+
• If you GOT it: edit it, then call hive_release when you finish.
|
|
119
|
+
• If you could NOT get it: someone else holds it — do NOT edit it. Pick other
|
|
120
|
+
work; call hive_claims to see what's open.
|
|
121
|
+
- Claims auto-expire, so a stalled agent never blocks the hive — but always
|
|
122
|
+
hive_release the moment you finish, so others can take it.
|
|
123
|
+
|
|
73
124
|
## While you work
|
|
74
125
|
4. ANNOUNCE first: post to chat what you are taking, e.g.
|
|
75
126
|
node hive-say.js <yourName> "taking auth.js: adding login validation"
|
|
@@ -91,8 +142,10 @@ another's work. The sync layer enforces the hard parts automatically; you do the
|
|
|
91
142
|
refactor) that touches another participant's area.
|
|
92
143
|
|
|
93
144
|
## Directed work + permission (the asymmetric gate)
|
|
94
|
-
11. AI -> AI is COORDINATION: if another agent hands you work, it is auto-accepted
|
|
95
|
-
and you may proceed.
|
|
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.
|
|
96
149
|
12. A HUMAN directing you is different. If a human who is NOT your owner assigns
|
|
97
150
|
you a task, you do NOT act on it — it stays PENDING until YOUR OWNER approves
|
|
98
151
|
("do it or ignore?"). Your own owner's requests proceed. Act only on tasks
|
|
@@ -117,6 +170,11 @@ another's work. The sync layer enforces the hard parts automatically; you do the
|
|
|
117
170
|
16. YOU triage: announce do-now vs after-current. Your OWNER can override anytime
|
|
118
171
|
— if your owner says "do it now" or "skip that", that wins.
|
|
119
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
|
+
|
|
120
178
|
Read → announce → patch → respect lanes → resolve conflicts → talk → triage pings → wait for approval.
|
|
121
179
|
`
|
|
122
180
|
|
|
@@ -146,6 +204,285 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
146
204
|
const tasks = doc.getMap('tasks') // id -> { id, to, by, text, status, decidedBy, at } directed work
|
|
147
205
|
const owners = doc.getMap('owners') // aiName -> ownerHumanName (who may approve its tasks)
|
|
148
206
|
const controls = doc.getMap('controls') // participantName -> { state: 'running'|'paused', by, at } (mission control)
|
|
207
|
+
// ROLLBACK metadata index: id -> { id, file, by, at, ts, churn, kind, label }. NO
|
|
208
|
+
// file content here — content is scope-sensitive and lives in each FILE's own doc
|
|
209
|
+
// (its `snap` map), so it inherits that file's access control. The parent only
|
|
210
|
+
// holds the timeline (who changed what, when) — same exposure as the manifest,
|
|
211
|
+
// which already lists every path. Lets a monitor (Control Room) show history.
|
|
212
|
+
const historyMeta = doc.getMap('history')
|
|
213
|
+
// The Hive coordination layer: a stigmergic claim map — region(file) -> {by,intent,at,ttl}.
|
|
214
|
+
// Agents SENSE → FLOW → CLAIM before editing so they don't collide. No central controller;
|
|
215
|
+
// claims evaporate via TTL so a stalled agent never deadlocks the hive.
|
|
216
|
+
const claims = doc.getMap('claims')
|
|
217
|
+
const swarmState = doc.getMap('swarm_state')
|
|
218
|
+
const swarmSchema = doc.getMap('swarm_schema')
|
|
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
|
+
}
|
|
149
486
|
// disableBc: do NOT sync peer-to-peer over BroadcastChannel. The relay must be
|
|
150
487
|
// the ONLY path between clients — otherwise two clients on the same machine would
|
|
151
488
|
// sync directly and bypass the relay's access control (auth, path scope, read-only).
|
|
@@ -165,15 +502,49 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
165
502
|
const fdoc = new Y.Doc()
|
|
166
503
|
const fprovider = new WebsocketProvider(relay, fileRoom(room, relPath), fdoc, wsOpts)
|
|
167
504
|
const text = fdoc.getText('content')
|
|
168
|
-
|
|
505
|
+
// `snap` (in the FILE's own doc, so it's scope-protected with the file) holds
|
|
506
|
+
// restore points: id -> { id, file, by, at, ts, content, churn, kind, label }.
|
|
507
|
+
// `undo` is a per-file UndoManager tracking THIS client's local edits, so a
|
|
508
|
+
// human/agent can undo their own CRDT changes (Level-0 rollback).
|
|
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')
|
|
531
|
+
const undo = new Y.UndoManager(text, { captureTimeout: 350 }) // default tracks local-origin txns
|
|
532
|
+
e = { doc: fdoc, provider: fprovider, text, snap, ledger, head, versions, forkmark, conflict, undo, synced: false }
|
|
169
533
|
fileDocs.set(relPath, e)
|
|
170
534
|
text.observe((_ev, txn) => { if (!txn.local) reconcile(relPath, 'remote') }) // remote content edits
|
|
171
|
-
|
|
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
|
|
172
542
|
return e
|
|
173
543
|
}
|
|
174
544
|
function closeFile(relPath) {
|
|
175
545
|
const e = fileDocs.get(relPath)
|
|
176
546
|
if (!e) return
|
|
547
|
+
try { e.undo && e.undo.destroy() } catch { }
|
|
177
548
|
try { e.provider.destroy() } catch { }
|
|
178
549
|
try { e.doc.destroy() } catch { }
|
|
179
550
|
fileDocs.delete(relPath)
|
|
@@ -184,15 +555,21 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
184
555
|
// try to open files I'm not granted — the relay would reject them anyway, but
|
|
185
556
|
// this avoids the churn and keeps out-of-scope files off my disk entirely.
|
|
186
557
|
// undefined = no path restriction (open room or whole-room grant).
|
|
187
|
-
let myPaths
|
|
188
|
-
if (token) { const pl = decodeUnsafe(token); const sc = pl && scopeForRoom(pl, room); myPaths = sc ? sc.paths : undefined }
|
|
558
|
+
let myPaths, myScope = null
|
|
559
|
+
if (token) { const pl = decodeUnsafe(token); const sc = pl && scopeForRoom(pl, room); myScope = sc || null; myPaths = sc ? sc.paths : undefined }
|
|
189
560
|
// canOpen gates BOTH scope (am I granted this path?) AND safety (is the path
|
|
190
561
|
// safe to write to disk — not a "../" traversal, absolute path, or control-char
|
|
191
562
|
// injection a malicious participant slipped into the shared manifest?).
|
|
192
563
|
const canOpen = (relPath) => isSafeRelPath(relPath) && pathAllowed(myPaths, relPath)
|
|
564
|
+
// canWrite: of the files I can SEE, which may I PUSH (edit/create in the room)?
|
|
565
|
+
// A view-only file (in my read scope but not my writePaths) is pull-only — I keep
|
|
566
|
+
// a local copy synced FROM the room but never publish my changes to it. No token
|
|
567
|
+
// or a full-write grant => everything I can open is writable (back-compat).
|
|
568
|
+
const canWrite = (relPath) => !myScope || writeAllowed(myScope, relPath)
|
|
193
569
|
|
|
194
570
|
const known = new Set()
|
|
195
571
|
const mtimes = new Map()
|
|
572
|
+
const shadow = new Map() // relPath -> exact bytes THIS ENGINE last wrote/captured (source-level edit capture)
|
|
196
573
|
const bases = new Map() // last synced content per file (advances on every reconcile)
|
|
197
574
|
const forkBases = new Map() // FORK POINT: what THIS author last saw/authored per file.
|
|
198
575
|
// forkBases advances only on local authorship / first adoption — NOT when a
|
|
@@ -226,11 +603,13 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
226
603
|
const full = path.join(ROOT, relPath)
|
|
227
604
|
fs.mkdirSync(path.dirname(full), { recursive: true })
|
|
228
605
|
fs.writeFileSync(full, content)
|
|
606
|
+
shadow.set(relPath, content) // record what THE ENGINE wrote — any later disk divergence is an authored edit
|
|
229
607
|
known.add(relPath)
|
|
230
608
|
try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
|
|
231
609
|
}
|
|
232
610
|
|
|
233
611
|
const conflicted = new Set() // files currently holding unresolved <<<<<<< markers
|
|
612
|
+
const icrWarned = new Map() // relPath -> last ICR semantic warning announced (dedupe)
|
|
234
613
|
function reconcile(relPath, origin = 'local') {
|
|
235
614
|
if (SKIP.has(relPath) || isIgnored(relPath)) return // never sync secrets/ignored files
|
|
236
615
|
if (!canOpen(relPath)) return // out-of-scope OR unsafe path (traversal/control char) — never materialize it
|
|
@@ -238,6 +617,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
238
617
|
const exists = fs.existsSync(full)
|
|
239
618
|
const disk = exists ? readText(full) : null
|
|
240
619
|
if (exists && disk === null) return // present on disk but binary/too-large to represent — leave it untouched (never clobber with stale doc text)
|
|
620
|
+
// View-only file: never PUSH our local change/creation (the relay would drop it
|
|
621
|
+
// anyway — it connects this file-room as a reader). Keep disk in sync with the
|
|
622
|
+
// shared copy if we already have it; a brand-new local-only file just stays local
|
|
623
|
+
// and is never registered in the manifest. Reads (origin !== 'local') flow on.
|
|
624
|
+
if (origin === 'local' && !canWrite(relPath)) {
|
|
625
|
+
const fe2 = fileDocs.get(relPath)
|
|
626
|
+
if (fe2 && fe2.synced) { const dt = fe2.text.toString(); if (dt && dt !== disk) writeToDisk(relPath, dt) }
|
|
627
|
+
return
|
|
628
|
+
}
|
|
241
629
|
// Per-file doc gating: we must know the RELAY's content before touching disk.
|
|
242
630
|
// Until the file-doc has synced, an empty Y.Text means "not pulled yet", not
|
|
243
631
|
// "empty file" — acting now would re-seed from disk and fork the CRDT history
|
|
@@ -246,24 +634,90 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
246
634
|
let fe = fileDocs.get(relPath)
|
|
247
635
|
if (!fe) { if (disk === null) return; openFile(relPath); return }
|
|
248
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
|
+
}
|
|
249
686
|
const docText = fe.text.toString()
|
|
250
687
|
if (docText === '' && !manifest.has(relPath)) {
|
|
251
688
|
// the relay has no copy of this path -> publish ours (a genuinely new file)
|
|
252
689
|
if (disk === null) return
|
|
253
690
|
fe.doc.transact(() => applyDiff(fe.text, disk))
|
|
691
|
+
stampProvenance(relPath, '', disk) // genesis: sign the file's first content (base = empty)
|
|
254
692
|
manifest.set(relPath, 1)
|
|
255
|
-
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)
|
|
256
694
|
try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
|
|
695
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'created' }); snappedBase.add(relPath)
|
|
257
696
|
return
|
|
258
697
|
}
|
|
259
698
|
const yt = fe.text
|
|
260
699
|
if (disk === null) { writeToDisk(relPath, docText); bases.set(relPath, docText); forkBases.set(relPath, docText); return }
|
|
261
700
|
if (disk === docText) {
|
|
262
|
-
known.add(relPath); bases.set(relPath, disk)
|
|
701
|
+
known.add(relPath); bases.set(relPath, disk); shadow.set(relPath, disk) // agreed state = capture baseline
|
|
263
702
|
// disk and doc already agree — a safe fork point, but only adopt it as
|
|
264
703
|
// THIS author's fork on first sight or our own local activity (a remote
|
|
265
704
|
// change landing on disk doesn't mean the local author has seen it).
|
|
266
705
|
if (!forkBases.has(relPath) || origin === 'local') forkBases.set(relPath, disk)
|
|
706
|
+
if (!snappedBase.has(relPath) && disk) { captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'baseline' }); snappedBase.add(relPath) }
|
|
707
|
+
return
|
|
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) }
|
|
267
721
|
return
|
|
268
722
|
}
|
|
269
723
|
const base = bases.has(relPath) ? bases.get(relPath) : disk
|
|
@@ -288,14 +742,33 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
288
742
|
if (integrated) {
|
|
289
743
|
res = { text: disk, conflict: false } // author built on the latest — trust it
|
|
290
744
|
} else {
|
|
291
|
-
res =
|
|
745
|
+
res = icrMerge3(fork, disk, docText, relPath)
|
|
292
746
|
reAdded = !res.conflict && res.text !== disk // we restored remote lines a stale write had dropped
|
|
293
747
|
}
|
|
294
748
|
} else {
|
|
295
|
-
res =
|
|
749
|
+
res = icrMerge3(base, disk, docText, relPath) // incoming remote change merged into our working copy
|
|
296
750
|
}
|
|
297
751
|
|
|
752
|
+
// Snapshot the state JUST BEFORE this author's edit lands, attributed to them —
|
|
753
|
+
// captured only on local origin so each change is recorded once, by its author.
|
|
754
|
+
if (origin === 'local' && res.text !== docText) {
|
|
755
|
+
const sc = summarizeChange(docText, res.text)
|
|
756
|
+
captureSnapshot(relPath, docText, name, { force: sc.isRewrite, churn: `${sc.changedLines}/${sc.totalLines} lines` })
|
|
757
|
+
lastLocalEdit = relPath
|
|
758
|
+
}
|
|
298
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
|
+
}
|
|
299
772
|
if (res.text !== disk) writeToDisk(relPath, res.text)
|
|
300
773
|
known.add(relPath); bases.set(relPath, res.text)
|
|
301
774
|
if (origin === 'local') forkBases.set(relPath, res.text) // we authored this state; it's our new fork
|
|
@@ -313,32 +786,206 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
313
786
|
} else if (reAdded) {
|
|
314
787
|
log(`[${name}] protected ${relPath}: your edit was based on an older version — re-added changes that arrived since (nobody's work lost)`)
|
|
315
788
|
try { say(`↺ ${relPath}: ${name}'s edit was based on an older copy — kept the changes that landed in between (nothing lost).`) } catch { }
|
|
789
|
+
} else if (!hasMarkers && res.icr === 'rename') {
|
|
790
|
+
log(`[${name}] ICR merged ${relPath} (detected a rename and updated the call sites)`)
|
|
791
|
+
} else if (!hasMarkers && res.icr === 'structural') {
|
|
792
|
+
log(`[${name}] ICR merged ${relPath} structurally (both edits kept, syntax guaranteed)`)
|
|
316
793
|
} else if (!hasMarkers) {
|
|
317
794
|
log(`[${name}] merged ${relPath} (both edits kept)`)
|
|
318
795
|
}
|
|
796
|
+
|
|
797
|
+
// ICR semantic warning: the text merged, but ICR saw a meaning-level problem a line
|
|
798
|
+
// merge can't (e.g. a dangling reference). Surface it once per distinct warning so it
|
|
799
|
+
// isn't lost — this is exactly the class of bug ICR exists to catch.
|
|
800
|
+
if (res.icrWarning && icrWarned.get(relPath) !== res.icrWarning) {
|
|
801
|
+
icrWarned.set(relPath, res.icrWarning)
|
|
802
|
+
log(`[${name}] ⚠ ICR flag in ${relPath} — ${res.icrWarning}`)
|
|
803
|
+
try { say(`⚠ ICR flag in ${relPath}: ${res.icrWarning}. It merged, but review — this is a break a plain merge can't see.`) } catch { }
|
|
804
|
+
} else if (!res.icrWarning && icrWarned.has(relPath)) {
|
|
805
|
+
icrWarned.delete(relPath)
|
|
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)
|
|
319
812
|
}
|
|
320
813
|
|
|
321
814
|
const fmtTime = () => new Date().toTimeString().slice(0, 8)
|
|
815
|
+
// Log file activity to the shared board: EVERY meaningful edit (so the live
|
|
816
|
+
// activity feed reflects ongoing work), tagged with whether it was a full-file
|
|
817
|
+
// `rewrite` and a numeric `ts` for ordering. HIVE_BOARD.md stays rewrites-only
|
|
818
|
+
// (see renderBoard) so the "read before editing" coordination doc isn't noisy.
|
|
322
819
|
function noteIfRewrite(relPath, base, next) {
|
|
323
|
-
if (
|
|
820
|
+
if (base == null || base === next) return
|
|
324
821
|
const s = summarizeChange(base, next)
|
|
325
|
-
if (!s.
|
|
326
|
-
board.set(relPath, { by: name, at: fmtTime(), churn: `${s.changedLines}/${s.totalLines} lines`, symbols: s.symbols })
|
|
327
|
-
log(`[${name}] board: logged REWRITE of ${relPath} (${s.changedLines}/${s.totalLines} lines; touched ${s.symbols.join(', ') || 'n/a'})`)
|
|
822
|
+
if (!s.changedLines) return
|
|
823
|
+
board.set(relPath, { by: name, at: fmtTime(), ts: Date.now(), churn: `${s.changedLines}/${s.totalLines} lines`, symbols: s.symbols, rewrite: s.isRewrite })
|
|
824
|
+
if (s.isRewrite) log(`[${name}] board: logged REWRITE of ${relPath} (${s.changedLines}/${s.totalLines} lines; touched ${s.symbols.join(', ') || 'n/a'})`)
|
|
328
825
|
}
|
|
329
826
|
function renderBoard() {
|
|
827
|
+
// HIVE_BOARD.md is the coordination doc: full-file REWRITES only (small edits
|
|
828
|
+
// live in the activity feed via the board map, but don't clutter this file).
|
|
829
|
+
const entries = [...board.entries()].map(([file, e]) => ({ file, ...e })).filter((e) => e.rewrite).sort((a, b) => (b.ts || 0) - (a.ts || 0) || (a.at < b.at ? 1 : -1))
|
|
830
|
+
if (!entries.length) return // no rewrites yet — don't create/clutter the board file
|
|
330
831
|
const out = [
|
|
331
832
|
'# Hive Board — recent full-file rewrites (auto-logged by Hivecode).',
|
|
332
833
|
'# READ THIS before editing a file someone just rewrote, then re-read that file.',
|
|
333
834
|
'',
|
|
334
835
|
]
|
|
335
|
-
const entries = [...board.entries()].map(([file, e]) => ({ file, ...e })).sort((a, b) => (a.at < b.at ? 1 : -1))
|
|
336
|
-
if (!entries.length) out.push('(no rewrites yet — patches and small edits are not listed)')
|
|
337
836
|
for (const e of entries) out.push(`- ${e.at} ${e.by} rewrote \`${e.file}\` (${e.churn}) — touched: ${(e.symbols || []).join(', ') || 'n/a'}`)
|
|
338
837
|
writeToDisk(BOARD_FILE, out.join('\n') + '\n')
|
|
339
838
|
}
|
|
340
839
|
if (syncFiles) board.observe(() => renderBoard())
|
|
341
840
|
|
|
841
|
+
// --- rollback: restore points + per-author revert + undo --------------------
|
|
842
|
+
// Every meaningful change captures a RESTORE POINT: the file's content from JUST
|
|
843
|
+
// BEFORE the edit, stored in that file's own doc (scope-safe) and indexed in the
|
|
844
|
+
// parent `history` (metadata only). Restoring is a normal forward edit (applyDiff
|
|
845
|
+
// back to the saved content) — so it propagates live to everyone, merges cleanly,
|
|
846
|
+
// and is itself recoverable. Because each client snapshots only ITS OWN local
|
|
847
|
+
// edits, every restore point is attributed to its true author exactly once.
|
|
848
|
+
const SNAP_KEEP = 30 // max AUTO restore points kept per file (manual ones never evicted)
|
|
849
|
+
const SNAP_MIN_GAP_MS = 4000 // throttle auto-capture per file (fine-grained undo is the editor's job)
|
|
850
|
+
const META_KEEP = 400 // global cap on the parent timeline index
|
|
851
|
+
const lastSnapAt = new Map() // relPath -> ts of last auto-capture
|
|
852
|
+
const snappedBase = new Set() // files we've already floored with a baseline snapshot
|
|
853
|
+
const snapId = () => 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
|
854
|
+
|
|
855
|
+
function pruneMeta() {
|
|
856
|
+
if (historyMeta.size <= META_KEEP) return
|
|
857
|
+
const all = [...historyMeta.values()].filter((e) => e.kind === 'auto').sort((a, b) => (a.ts || 0) - (b.ts || 0))
|
|
858
|
+
const overflow = historyMeta.size - META_KEEP
|
|
859
|
+
if (overflow <= 0) return
|
|
860
|
+
doc.transact(() => { for (let i = 0; i < Math.min(overflow, all.length); i++) historyMeta.delete(all[i].id) })
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Capture `content` as a restore point for relPath, attributed to `byWho`.
|
|
864
|
+
// kind: 'auto' (an ordinary edit), 'base' (a file's floor), 'manual' (a named
|
|
865
|
+
// checkpoint), 'restore' (the pre-restore state, kept so restore is reversible).
|
|
866
|
+
function captureSnapshot(relPath, content, byWho, { force = false, churn = '', kind = 'auto', label = '' } = {}) {
|
|
867
|
+
if (content == null) return null
|
|
868
|
+
const fe = fileDocs.get(relPath); if (!fe) return null
|
|
869
|
+
const now = Date.now()
|
|
870
|
+
if (kind === 'auto' && !force && now - (lastSnapAt.get(relPath) || 0) < SNAP_MIN_GAP_MS) return null
|
|
871
|
+
const snap = fe.snap
|
|
872
|
+
let latest = null
|
|
873
|
+
for (const e of snap.values()) if (!latest || (e.ts || 0) > (latest.ts || 0)) latest = e
|
|
874
|
+
if (latest && latest.content === content) return null // identical to the most recent point — nothing to keep
|
|
875
|
+
const id = snapId()
|
|
876
|
+
const at = fmtTime()
|
|
877
|
+
const pruned = []
|
|
878
|
+
fe.doc.transact(() => {
|
|
879
|
+
snap.set(id, { id, file: relPath, by: byWho, at, ts: now, content, churn, kind, label })
|
|
880
|
+
const autos = [...snap.values()].filter((e) => e.kind === 'auto').sort((a, b) => (a.ts || 0) - (b.ts || 0))
|
|
881
|
+
for (let i = 0; i < autos.length - SNAP_KEEP; i++) { snap.delete(autos[i].id); pruned.push(autos[i].id) }
|
|
882
|
+
})
|
|
883
|
+
if (kind === 'auto') lastSnapAt.set(relPath, now)
|
|
884
|
+
doc.transact(() => {
|
|
885
|
+
historyMeta.set(id, { id, file: relPath, by: byWho, at, ts: now, churn, kind, label }) // metadata only — no content
|
|
886
|
+
for (const pid of pruned) historyMeta.delete(pid) // keep the index in step with evicted content
|
|
887
|
+
})
|
|
888
|
+
pruneMeta()
|
|
889
|
+
return id
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Find which file a restore-point id belongs to (via the index, else by scanning
|
|
893
|
+
// open file docs as a fallback).
|
|
894
|
+
function fileOfSnap(id) {
|
|
895
|
+
const m = historyMeta.get(id)
|
|
896
|
+
if (m && m.file) return m.file
|
|
897
|
+
for (const [rp, fe] of fileDocs) if (fe.snap.has(id)) return rp
|
|
898
|
+
return null
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Restore a file to a saved restore point. It first snapshots the CURRENT state
|
|
902
|
+
// (so the restore can itself be undone), then writes the saved content back as a
|
|
903
|
+
// normal edit — which syncs to everyone and to disk.
|
|
904
|
+
function restore(id, by = name) {
|
|
905
|
+
const relPath = fileOfSnap(id)
|
|
906
|
+
if (!relPath) return { error: 'restore point not found' }
|
|
907
|
+
if (!canOpen(relPath)) return { error: `${relPath} is out of your scope` }
|
|
908
|
+
if (!canWrite(relPath)) return { error: `${relPath} is read-only for you` }
|
|
909
|
+
let fe = fileDocs.get(relPath)
|
|
910
|
+
if (!fe) { openFile(relPath); fe = fileDocs.get(relPath) }
|
|
911
|
+
if (!fe) return { error: 'could not open file' }
|
|
912
|
+
const entry = fe.snap.get(id)
|
|
913
|
+
if (!entry || entry.content == null) return { error: 'restore point content unavailable (it may have aged out)' }
|
|
914
|
+
const current = fe.text.toString()
|
|
915
|
+
if (current === entry.content) return { ok: true, unchanged: true, file: relPath }
|
|
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
|
|
918
|
+
fe.doc.transact(() => applyDiff(fe.text, entry.content))
|
|
919
|
+
writeToDisk(relPath, entry.content)
|
|
920
|
+
bases.set(relPath, entry.content); forkBases.set(relPath, entry.content)
|
|
921
|
+
try { say(`↩ ${by} restored ${relPath} to ${entry.at}${entry.by && entry.by !== by ? ` (state before ${entry.by}'s edit)` : ''}.`) } catch { }
|
|
922
|
+
return { ok: true, file: relPath }
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Restore one file to its state as of timestamp `ts` (the newest point at/below ts).
|
|
926
|
+
function restoreFileTo(relPath, ts, by = name) {
|
|
927
|
+
const fe = fileDocs.get(relPath)
|
|
928
|
+
if (!fe) return { error: 'file not open' }
|
|
929
|
+
let best = null
|
|
930
|
+
for (const e of fe.snap.values()) if ((e.ts || 0) <= ts && (!best || (e.ts || 0) > (best.ts || 0))) best = e
|
|
931
|
+
if (!best) return { error: 'no restore point at/before that time' }
|
|
932
|
+
return restore(best.id, by)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Revert everything an author did since `sinceTs` (default: all of it): for each
|
|
936
|
+
// file they touched, roll back to the state BEFORE their first edit in the window.
|
|
937
|
+
// Honest limitation: this is checkpoint-granular, so if SOMEONE ELSE also edited
|
|
938
|
+
// the same file after that point, their edits in that file are rolled back too.
|
|
939
|
+
// For the dangerous case — an agent rewriting files — that's exactly right.
|
|
940
|
+
function revertAuthor(who, sinceTs = 0, by = name) {
|
|
941
|
+
if (!who) return { error: 'no author given' }
|
|
942
|
+
const earliest = new Map() // file -> earliest meta authored by `who` in the window
|
|
943
|
+
for (const m of historyMeta.values()) {
|
|
944
|
+
if (m.by !== who || (m.ts || 0) < sinceTs) continue
|
|
945
|
+
const cur = earliest.get(m.file)
|
|
946
|
+
if (!cur || (m.ts || 0) < (cur.ts || 0)) earliest.set(m.file, m)
|
|
947
|
+
}
|
|
948
|
+
const files = []
|
|
949
|
+
for (const [file, m] of earliest) files.push({ file, ...restore(m.id, by) })
|
|
950
|
+
const ok = files.filter((f) => f.ok).length
|
|
951
|
+
if (files.length) { try { say(`⏮ ${by} reverted ${who}'s changes across ${ok}/${files.length} file(s).`) } catch { } }
|
|
952
|
+
return { ok: true, reverted: ok, files }
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// The timeline (newest first), read from the parent index. Optional filters.
|
|
956
|
+
function listHistory({ file = null, by = null, limit = 200 } = {}) {
|
|
957
|
+
return [...historyMeta.values()]
|
|
958
|
+
.filter((e) => (!file || e.file === file) && (!by || e.by === by))
|
|
959
|
+
.sort((a, b) => (b.ts || 0) - (a.ts || 0))
|
|
960
|
+
.slice(0, limit)
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// A named manual checkpoint of a file's current state (never auto-evicted).
|
|
964
|
+
function checkpoint(relPath, label = '', by = name) {
|
|
965
|
+
const fe = fileDocs.get(relPath); if (!fe) return { error: 'file not open' }
|
|
966
|
+
const id = captureSnapshot(relPath, fe.text.toString(), by, { force: true, kind: 'manual', label: label || `checkpoint by ${by}` })
|
|
967
|
+
return id ? { ok: true, id } : { ok: true, unchanged: true }
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Level-0 undo/redo: a client undoes its OWN recent CRDT edits to a file. (In an
|
|
971
|
+
// editor, native Ctrl-Z already covers your own typing; this also covers edits
|
|
972
|
+
// applied programmatically and keeps the doc, disk, and peers in step.)
|
|
973
|
+
let lastLocalEdit = null
|
|
974
|
+
function undoLast() {
|
|
975
|
+
const rp = lastLocalEdit; const fe = rp && fileDocs.get(rp)
|
|
976
|
+
if (!fe || !fe.undo || !fe.undo.canUndo()) return { error: 'nothing to undo' }
|
|
977
|
+
fe.undo.undo()
|
|
978
|
+
const t = fe.text.toString(); writeToDisk(rp, t); bases.set(rp, t); forkBases.set(rp, t)
|
|
979
|
+
return { ok: true, file: rp }
|
|
980
|
+
}
|
|
981
|
+
function redoLast() {
|
|
982
|
+
const rp = lastLocalEdit; const fe = rp && fileDocs.get(rp)
|
|
983
|
+
if (!fe || !fe.undo || !fe.undo.canRedo()) return { error: 'nothing to redo' }
|
|
984
|
+
fe.undo.redo()
|
|
985
|
+
const t = fe.text.toString(); writeToDisk(rp, t); bases.set(rp, t); forkBases.set(rp, t)
|
|
986
|
+
return { ok: true, file: rp }
|
|
987
|
+
}
|
|
988
|
+
|
|
342
989
|
// --- coordination channel: the agents (and humans) talk to each other ---
|
|
343
990
|
// say() posts a message into the shared, ordered chat. Everyone renders the
|
|
344
991
|
// whole conversation to HIVE_CHAT.md locally, so an agent just READS that file
|
|
@@ -544,6 +1191,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
544
1191
|
provider.on('sync', (s) => {
|
|
545
1192
|
if (!s || !syncFiles) return
|
|
546
1193
|
writeToDisk(RULES_FILE, HIVE_RULES_TEXT) // the law is always present in the room
|
|
1194
|
+
writeToDisk(AGENTS_FILE, AGENT_MANUAL_TEXT) // so an agent opening this folder learns how to join + behave
|
|
547
1195
|
for (const key of manifest.keys()) if (canOpen(key)) openFile(key) // connect to every file I'm granted; each file-room's sync pulls its content
|
|
548
1196
|
if (board.size) renderBoard()
|
|
549
1197
|
if (chat.length) renderChat()
|
|
@@ -560,6 +1208,23 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
560
1208
|
return {
|
|
561
1209
|
doc,
|
|
562
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
|
+
},
|
|
563
1228
|
say, // post a coordination message
|
|
564
1229
|
assign, // direct a task at a participant (needs approval if target AI has an owner)
|
|
565
1230
|
decide, // approve/deny a task (owner only, if set)
|
|
@@ -568,6 +1233,33 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
568
1233
|
control, // pause/resume an agent (mission control)
|
|
569
1234
|
isPaused, // is a participant currently paused?
|
|
570
1235
|
controls, // the live control map
|
|
1236
|
+
// --- hive coordination: claim a region BEFORE editing (collision prevention) ---
|
|
1237
|
+
claim: (region, intent) => coordinator.acquire(String(region), String(intent || 'edit')), // true iff you hold it
|
|
1238
|
+
release: (region) => coordinator.release(String(region)),
|
|
1239
|
+
senseClaim: (region) => coordinator.sense(String(region)), // the live claim on a region, or null
|
|
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
|
+
},
|
|
1254
|
+
// --- rollback ---
|
|
1255
|
+
listHistory, // the restore-point timeline (newest first; filter by file/by)
|
|
1256
|
+
restore, // restore a file to a restore point by id (reversible)
|
|
1257
|
+
restoreFileTo, // restore one file to its state as of a timestamp
|
|
1258
|
+
revertAuthor, // roll back everything an author did since a time
|
|
1259
|
+
checkpoint, // save a named restore point of a file's current state
|
|
1260
|
+
captureSnapshot, // (low-level) capture a restore point
|
|
1261
|
+
undoLast, // undo this client's last local edit
|
|
1262
|
+
redoLast, // redo it
|
|
571
1263
|
// One entry PER NAME, not per connection: the same user open in two windows
|
|
572
1264
|
// (e.g. the editor + the browser Control Room) is ONE member. Merge their
|
|
573
1265
|
// fields so we keep whichever connection has an active `editing`/`owner`.
|