hivecode-mcp 0.11.6 → 0.12.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/HIVE_RULES.md +127 -127
- package/README.md +250 -13
- package/core.js +14 -146
- package/docs/MCP.md +1 -1
- package/docs/SELF_HOSTING.md +253 -0
- package/hive-activity.js +310 -0
- package/hive-mcp.js +343 -68
- package/hive-pr-staleness.js +189 -0
- package/hive-privacy.js +263 -0
- package/hive-relay-health.js +205 -0
- package/hive-version.js +59 -0
- package/lang-js.js +11 -2
- package/package.json +15 -2
- package/reconcile.cjs +477 -0
- package/sync.js +515 -26
package/hive-version.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// hive-version.js — one place that knows what build this is.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS FILE EXISTS, and it is not tidiness.
|
|
4
|
+
//
|
|
5
|
+
// VERSION is load-bearing, not cosmetic. hive_join instructs every arriving agent to announce its
|
|
6
|
+
// version in chat, because two clients running different builds in one room corrupt each other's
|
|
7
|
+
// files with no error on either side — both writes succeed, both are green, and the disagreement
|
|
8
|
+
// surfaces only as lost bytes. That announcement is the ONLY channel that makes a mixed build
|
|
9
|
+
// visible from inside the room. So a version string is not a label; it is the input to the one
|
|
10
|
+
// detector we have.
|
|
11
|
+
//
|
|
12
|
+
// On 20 Sep 2026 three entry points were found reporting a version package.json does not contain:
|
|
13
|
+
// remote-mcp.js said '0.11.5' while live on the hosted relay, hive-mcp-http.js and hive-linear.js
|
|
14
|
+
// both said '1.0.0'. hive-mcp.js had already been fixed months earlier, with a comment explaining
|
|
15
|
+
// exactly why hardcoding it is dangerous — and that fix was never carried to the other three.
|
|
16
|
+
// A detector that lies is strictly worse than no detector, because it is trusted.
|
|
17
|
+
//
|
|
18
|
+
// HIV-P7 closed that by giving each of the four files its own runtime read. Which produced the
|
|
19
|
+
// next defect, and it was measured rather than predicted: with the reader duplicated four times,
|
|
20
|
+
// corrupting ONE copy's fallback string to '9.9.9-DRIFTED' left tests/version-truth-test.mjs at
|
|
21
|
+
// 16 passed, 0 failed. The property guards the version that gets REPORTED and says nothing about
|
|
22
|
+
// the mechanism that produces it, so three of four copies could rot in silence.
|
|
23
|
+
//
|
|
24
|
+
// This codebase has watched that happen before. The extension's reconcile() and the engine's were
|
|
25
|
+
// the same function once; they now share 34 lines and behave differently. Eight lines is not too
|
|
26
|
+
// small to drift — it is exactly small enough that nobody feels the need to check.
|
|
27
|
+
//
|
|
28
|
+
// So the fix is DELETION, not another assertion. One definition, imported by all four. The
|
|
29
|
+
// property becomes true by construction instead of by policing, and a fifth entry point written
|
|
30
|
+
// next year gets it right by importing rather than by remembering.
|
|
31
|
+
//
|
|
32
|
+
// TWO DECISIONS WORTH KEEPING:
|
|
33
|
+
//
|
|
34
|
+
// It READS package.json rather than being told. package.json always ships beside these files —
|
|
35
|
+
// in the repo and in the published tarball — so `new URL('./package.json', import.meta.url)`
|
|
36
|
+
// resolves in both. Anything else (a constant, a build step, a generated file) can disagree with
|
|
37
|
+
// the package the moment `npm version` runs, which is precisely the failure being fixed.
|
|
38
|
+
//
|
|
39
|
+
// IT NEVER THROWS. A version read is not worth a server that will not boot, and a server that
|
|
40
|
+
// refuses to start is a worse outcome than one that says it does not know which build it is. So
|
|
41
|
+
// an unreadable or malformed package.json yields '0.0.0-unknown' — a value that is obviously
|
|
42
|
+
// wrong to a human, will never match a real release, and cannot be mistaken for a true answer.
|
|
43
|
+
// Silence or an empty string could be read as "no mixed build here"; this cannot.
|
|
44
|
+
|
|
45
|
+
import fs from 'fs'
|
|
46
|
+
|
|
47
|
+
/** Recomputed never: a running process cannot change which build it is. */
|
|
48
|
+
export const VERSION = (() => {
|
|
49
|
+
try {
|
|
50
|
+
const v = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version
|
|
51
|
+
if (typeof v === 'string' && v) return v
|
|
52
|
+
} catch { /* fall through — see IT NEVER THROWS above */ }
|
|
53
|
+
return '0.0.0-unknown'
|
|
54
|
+
})()
|
|
55
|
+
|
|
56
|
+
/** The sentinel, exported so callers and tests can name it instead of retyping the literal. */
|
|
57
|
+
export const UNKNOWN_VERSION = '0.0.0-unknown'
|
|
58
|
+
|
|
59
|
+
export default VERSION
|
package/lang-js.js
CHANGED
|
@@ -128,11 +128,20 @@ function bareName(node) {
|
|
|
128
128
|
function declaredNames(src) {
|
|
129
129
|
const s = new Set()
|
|
130
130
|
for (const node of parse(src).body) {
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
let n = node
|
|
132
|
+
if (n.type === 'ExportNamedDeclaration' && n.declaration) n = n.declaration
|
|
133
|
+
if (n.type === 'VariableDeclaration') {
|
|
134
|
+
for (const d of n.declarations) bindingNames(d.id, s)
|
|
135
|
+
} else {
|
|
136
|
+
const nm = bareName(node); if (nm) s.add(nm)
|
|
137
|
+
}
|
|
138
|
+
// Import locals count as declared names too — so if an import is ever dropped and its
|
|
133
139
|
// binding is still used, the dangling-reference check catches it (safety net for the
|
|
134
140
|
// import merge). Keeps the never-emit-broken-code guarantee honest for imports.
|
|
135
141
|
if (node.type === 'ImportDeclaration') for (const sp of node.specifiers) s.add(sp.local.name)
|
|
142
|
+
if (node.type === 'ExportNamedDeclaration' && !node.declaration && node.specifiers) {
|
|
143
|
+
for (const sp of node.specifiers) s.add(sp.exported.name || sp.exported.value || sp.local.name)
|
|
144
|
+
}
|
|
136
145
|
}
|
|
137
146
|
return s
|
|
138
147
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hivecode-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "MCP server that lets any AI coding agent join a Hivecode room — humans and multiple AI agents edit one live codebase with folder-scoped permissions and approval gates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,11 +28,18 @@
|
|
|
28
28
|
},
|
|
29
29
|
"files": [
|
|
30
30
|
"hive-mcp.js",
|
|
31
|
+
"hive-version.js",
|
|
32
|
+
"hive-pr-staleness.js",
|
|
31
33
|
"hive-mailbox.js",
|
|
32
34
|
"hive-obligations.js",
|
|
33
35
|
"hive-receipts.js",
|
|
34
36
|
"hive-throttle.js",
|
|
35
37
|
"hive-instructions.js",
|
|
38
|
+
"hive-activity.js",
|
|
39
|
+
"hive-relay-health.js",
|
|
40
|
+
"hive-privacy.js",
|
|
41
|
+
"docs/SELF_HOSTING.md",
|
|
42
|
+
"reconcile.cjs",
|
|
36
43
|
"hive-busy.js",
|
|
37
44
|
"hive-stop-hook.js",
|
|
38
45
|
"hive-hook-install.js",
|
|
@@ -72,7 +79,13 @@
|
|
|
72
79
|
"test:features": "node tests/features-test.mjs",
|
|
73
80
|
"test:features:shape": "node tests/features-test.mjs --shape",
|
|
74
81
|
"test:learnings": "node tests/learnings-test.mjs",
|
|
75
|
-
"test:
|
|
82
|
+
"test:docs": "node tests/readme-promises-test.mjs",
|
|
83
|
+
"test:recreate": "node tests/blind-recreate-test.mjs",
|
|
84
|
+
"test:liveness": "node tests/agent-liveness-test.mjs && node tests/unreachable-recipient-test.mjs",
|
|
85
|
+
"build:ext": "node scripts/build-icr-cjs.cjs && node scripts/build-privacy-cjs.cjs",
|
|
86
|
+
"test:selfhost": "node tests/relay-pin-test.mjs && node tests/self-host-test.mjs && node tests/extension-privacy-parity-test.mjs",
|
|
87
|
+
"test:hardening": "node tests/genesis-claim-test.mjs && node tests/bare-root-write-test.mjs && node tests/version-truth-test.mjs && node tests/room-repo-drift-test.mjs && node tests/first-contact-refusal-test.mjs && node tests/open-file-cap-test.mjs && node tests/learnings-test.mjs && node tests/agent-liveness-test.mjs && node tests/unreachable-recipient-test.mjs && node tests/join-honesty-test.mjs && node tests/relay-diagnosis-test.mjs && node tests/blind-recreate-test.mjs && node tests/genesis-collision-test.mjs && node tests/relay-pin-test.mjs && node tests/self-host-test.mjs && node tests/extension-privacy-parity-test.mjs && node tests/readme-promises-test.mjs",
|
|
88
|
+
"test:extension": "node tests/extension-parity-test.mjs && node tests/extension-privacy-parity-test.mjs",
|
|
76
89
|
"test:ceiling": "node tests/refused-visible-test.mjs && node tests/ceiling-no-data-loss-test.mjs",
|
|
77
90
|
"test:identity": "node tests/identity-audit-test.mjs",
|
|
78
91
|
"test:billing": "node tests/billing-test.mjs",
|
package/reconcile.cjs
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
// reconcile.cjs — Single source of truth for the 3-way reconcile & merge engine.
|
|
2
|
+
//
|
|
3
|
+
// CommonJS on purpose: sync.js is ESM and the VS Code extension is CJS.
|
|
4
|
+
// A .cjs file is the one shape both can load synchronously without duplication.
|
|
5
|
+
//
|
|
6
|
+
// Acceptance criteria (HIV-8), re-checked 21 Sep 2026 -- FOUR OF FIVE MET:
|
|
7
|
+
// 1. MET one module exports reconcile() and its dependencies (11 exports).
|
|
8
|
+
// 2. MET core.js re-exports from here and sync.js imports core.js. One copy.
|
|
9
|
+
// 3. NOT MET extension/extension.js does NOT require this file. It requires
|
|
10
|
+
// ./icr.cjs (the ICR bundle) and keeps its OWN reconcile() at
|
|
11
|
+
// extension/extension.js:1166. The second copy is still there.
|
|
12
|
+
// 4. MET node tests/test.js -- 95 passed, 0 failed.
|
|
13
|
+
// 5. MET node tests/conflict-test.mjs -- ALL 4 PASS.
|
|
14
|
+
//
|
|
15
|
+
// Criterion 3 read "No second copy anywhere" while a second copy existed, in a file
|
|
16
|
+
// that ships in package.json files[]. A comment asserting an invariant it does not
|
|
17
|
+
// hold is worse than no comment: the next reader trusts it and stops looking. Corrected
|
|
18
|
+
// to what is true rather than deleted, because the gap is the useful part.
|
|
19
|
+
//
|
|
20
|
+
// WHAT ACTUALLY GUARDS THE DRIFT, since it is not this file:
|
|
21
|
+
// tests/extension-parity-test.mjs the ICR bundle vs the engine, 24/24
|
|
22
|
+
// tests/extension-reconcile-characterisation-test.mjs pins the extension's own copy
|
|
23
|
+
// Those two measure that the copies AGREE. They do not make it one copy, and closing
|
|
24
|
+
// criterion 3 for real means the extension loading this module.
|
|
25
|
+
|
|
26
|
+
const fs = require('fs')
|
|
27
|
+
const path = require('path')
|
|
28
|
+
|
|
29
|
+
// Minimal text diff: replace only the changed middle, preserving the common
|
|
30
|
+
// prefix/suffix. Applied to a Y.Text so edits stay small (good for CRDT churn).
|
|
31
|
+
function applyDiff(ytext, next) {
|
|
32
|
+
const cur = ytext.toString()
|
|
33
|
+
if (cur === next) return false
|
|
34
|
+
let p = 0
|
|
35
|
+
while (p < cur.length && p < next.length && cur[p] === next[p]) p++
|
|
36
|
+
let s = 0
|
|
37
|
+
while (s < cur.length - p && s < next.length - p && cur[cur.length - 1 - s] === next[next.length - 1 - s]) s++
|
|
38
|
+
if (cur.length - p - s > 0) ytext.delete(p, cur.length - p - s)
|
|
39
|
+
const ins = next.slice(p, next.length - s)
|
|
40
|
+
if (ins) ytext.insert(p, ins)
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Detect REAL git-style conflict markers in a file. Line-anchored on purpose.
|
|
45
|
+
function hasConflictMarkers(text) {
|
|
46
|
+
return /^<<<<<<< /m.test(text || '') && /^>>>>>>> /m.test(text || '')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Strip and de-nest conflict markers to prevent exponential 2^N code ballooning
|
|
50
|
+
function stripConflictMarkers(text, prefer = 'local') {
|
|
51
|
+
if (!text || !hasConflictMarkers(text)) return text || ''
|
|
52
|
+
const lines = text.split('\n')
|
|
53
|
+
const out = []
|
|
54
|
+
let inConflict = false
|
|
55
|
+
let inTheirs = false
|
|
56
|
+
const localBlock = []
|
|
57
|
+
const theirsBlock = []
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
const line = lines[i]
|
|
61
|
+
if (/^<<<<<<< /.test(line)) {
|
|
62
|
+
inConflict = true
|
|
63
|
+
inTheirs = false
|
|
64
|
+
localBlock.length = 0
|
|
65
|
+
theirsBlock.length = 0
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
if (/^=======$/.test(line) && inConflict) {
|
|
69
|
+
inTheirs = true
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
if (/^>>>>>>> /.test(line) && inConflict) {
|
|
73
|
+
inConflict = false
|
|
74
|
+
out.push(...(prefer === 'theirs' ? theirsBlock : localBlock))
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
if (inConflict) {
|
|
78
|
+
if (inTheirs) theirsBlock.push(line)
|
|
79
|
+
else localBlock.push(line)
|
|
80
|
+
} else {
|
|
81
|
+
out.push(line)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out.join('\n')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Which base line-range an edit replaced, and the replacement lines.
|
|
88
|
+
function changedRange(base, other) {
|
|
89
|
+
let p = 0
|
|
90
|
+
while (p < base.length && p < other.length && base[p] === other[p]) p++
|
|
91
|
+
let s = 0
|
|
92
|
+
while (s < base.length - p && s < other.length - p && base[base.length - 1 - s] === other[other.length - 1 - s]) s++
|
|
93
|
+
return { startBase: p, endBase: base.length - s, newLines: other.slice(p, other.length - s) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Best-effort "what was defined here" across common languages — for the board.
|
|
97
|
+
function symbolsIn(text) {
|
|
98
|
+
const names = new Set()
|
|
99
|
+
const add = (re) => { let m; while ((m = re.exec(text)) && names.size < 8) names.add(m[1]) }
|
|
100
|
+
add(/\bfunction\s+(\w+)/g)
|
|
101
|
+
add(/\bclass\s+(\w+)/g)
|
|
102
|
+
add(/\bdef\s+(\w+)/g)
|
|
103
|
+
add(/\b(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=/g)
|
|
104
|
+
return [...names].slice(0, 8)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Classify a change as a small PATCH vs wholesale REWRITE.
|
|
108
|
+
function summarizeChange(base, next) {
|
|
109
|
+
const b = (base || '').split('\n')
|
|
110
|
+
const n = (next || '').split('\n')
|
|
111
|
+
if (!base) return { isRewrite: false, changedLines: n.length, totalLines: n.length, symbols: symbolsIn(next || '') }
|
|
112
|
+
const r = changedRange(b, n)
|
|
113
|
+
const churn = Math.max(r.endBase - r.startBase, r.newLines.length)
|
|
114
|
+
const total = Math.max(b.length, n.length)
|
|
115
|
+
const isRewrite = total > 0 && churn >= 4 && churn / total >= 0.5
|
|
116
|
+
return { isRewrite, changedLines: churn, totalLines: total, symbols: symbolsIn(r.newLines.join('\n')) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 3-way merge for the sync layer (disk <-> shared doc)
|
|
120
|
+
function merge3(base, mine, theirs) {
|
|
121
|
+
if (mine === theirs) return { text: mine, conflict: false }
|
|
122
|
+
if (base === theirs) return { text: mine, conflict: false }
|
|
123
|
+
if (base === mine) return { text: theirs, conflict: false }
|
|
124
|
+
|
|
125
|
+
const cleanBase = hasConflictMarkers(base) ? stripConflictMarkers(base, 'local') : (base || '')
|
|
126
|
+
const cleanMine = hasConflictMarkers(mine) ? stripConflictMarkers(mine, 'local') : (mine || '')
|
|
127
|
+
const cleanTheirs = hasConflictMarkers(theirs) ? stripConflictMarkers(theirs, 'theirs') : (theirs || '')
|
|
128
|
+
|
|
129
|
+
if (cleanMine === cleanTheirs) return { text: cleanMine, conflict: false }
|
|
130
|
+
if (cleanBase === cleanTheirs) return { text: cleanMine, conflict: false }
|
|
131
|
+
if (cleanBase === cleanMine) return { text: cleanTheirs, conflict: false }
|
|
132
|
+
|
|
133
|
+
const b = cleanBase.split('\n')
|
|
134
|
+
const mr = changedRange(b, cleanMine.split('\n'))
|
|
135
|
+
const tr = changedRange(b, cleanTheirs.split('\n'))
|
|
136
|
+
if (mr.endBase <= tr.startBase || tr.endBase <= mr.startBase) {
|
|
137
|
+
const out = b.slice()
|
|
138
|
+
for (const e of [mr, tr].sort((x, y) => y.startBase - x.startBase)) {
|
|
139
|
+
out.splice(e.startBase, e.endBase - e.startBase, ...e.newLines)
|
|
140
|
+
}
|
|
141
|
+
return { text: out.join('\n'), conflict: false }
|
|
142
|
+
}
|
|
143
|
+
const start = Math.min(mr.startBase, tr.startBase)
|
|
144
|
+
const end = Math.max(mr.endBase, tr.endBase)
|
|
145
|
+
const mineBlock = [...b.slice(start, mr.startBase), ...mr.newLines, ...b.slice(mr.endBase, end)]
|
|
146
|
+
const theirsBlock = [...b.slice(start, tr.startBase), ...tr.newLines, ...b.slice(tr.endBase, end)]
|
|
147
|
+
const out = [
|
|
148
|
+
...b.slice(0, start),
|
|
149
|
+
'<<<<<<< local (yours)',
|
|
150
|
+
...mineBlock,
|
|
151
|
+
'=======',
|
|
152
|
+
...theirsBlock,
|
|
153
|
+
'>>>>>>> incoming (theirs)',
|
|
154
|
+
...b.slice(end),
|
|
155
|
+
]
|
|
156
|
+
return { text: out.join('\n'), conflict: true }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function describeConflicts(cs) {
|
|
160
|
+
return (cs || []).map((c) =>
|
|
161
|
+
c.startsWith('ref:') ? `'${c.slice(4)}' was removed or renamed but is still used`
|
|
162
|
+
: c.startsWith('fn:') ? `both sides changed function ${c.slice(3)}`
|
|
163
|
+
: c.startsWith('class:') ? `both sides changed class ${c.slice(6)}`
|
|
164
|
+
: `both sides changed ${c.replace(/^\w+:/, '')}`).join('; ')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function icrMerge3(base, mine, theirs, relPath, icrlib) {
|
|
168
|
+
const lm = merge3(base, mine, theirs)
|
|
169
|
+
if (!icrlib || !icrlib.supports || !icrlib.supports(relPath)) return lm
|
|
170
|
+
let r
|
|
171
|
+
try { r = icrlib.structuralMerge(base, mine, theirs, { filename: relPath }) } catch (e) { return lm }
|
|
172
|
+
if (r.status === 'auto') {
|
|
173
|
+
const renamed = r.renames && r.renames.length
|
|
174
|
+
return { text: r.text, conflict: false, icr: renamed ? 'rename' : 'structural', icrWarning: renamed ? `auto-applied rename ${r.renames.join(', ')}` : undefined }
|
|
175
|
+
}
|
|
176
|
+
if (r.status === 'semantic-conflict') return Object.assign({}, lm, { icrWarning: describeConflicts(r.conflicts) })
|
|
177
|
+
return lm
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// createReconciler: binds to a session's maps, state, and callbacks, returning
|
|
181
|
+
// a reconcile(relPath, origin = 'local') function.
|
|
182
|
+
function createReconciler(ctx) {
|
|
183
|
+
const {
|
|
184
|
+
root,
|
|
185
|
+
name = 'anon',
|
|
186
|
+
SKIP = new Set(['HIVE_BOARD.md', 'HIVE_CHAT.md', 'HIVE_TASKS.md', 'HIVE_RULES.md', 'HIVE_MEMBERS.md', 'HIVE_FOR_AGENTS.md', '.hive.json']),
|
|
187
|
+
isIgnored = () => false,
|
|
188
|
+
canOpen = () => true,
|
|
189
|
+
canWrite = () => true,
|
|
190
|
+
readText = (full) => {
|
|
191
|
+
try {
|
|
192
|
+
const buf = fs.readFileSync(full)
|
|
193
|
+
if (buf.length > 1_000_000 || buf.includes(0)) return null
|
|
194
|
+
return buf.toString('utf8')
|
|
195
|
+
} catch { return null }
|
|
196
|
+
},
|
|
197
|
+
writeToDisk,
|
|
198
|
+
openFile,
|
|
199
|
+
touchFile,
|
|
200
|
+
fileDocs,
|
|
201
|
+
manifest,
|
|
202
|
+
bases,
|
|
203
|
+
forkBases,
|
|
204
|
+
known,
|
|
205
|
+
mtimes,
|
|
206
|
+
conflicted = new Set(),
|
|
207
|
+
icrWarned = new Map(),
|
|
208
|
+
snappedBase = new Set(),
|
|
209
|
+
preexisting = new Set(),
|
|
210
|
+
priorShadow,
|
|
211
|
+
shadow,
|
|
212
|
+
say = () => {},
|
|
213
|
+
log = () => {},
|
|
214
|
+
markEditing = () => {},
|
|
215
|
+
noteCoEditing = () => {},
|
|
216
|
+
noteIfRewrite = () => {},
|
|
217
|
+
captureSnapshot = () => {},
|
|
218
|
+
lastLocalEdit,
|
|
219
|
+
// Fork-gate & provenance
|
|
220
|
+
FORK_GATE = false,
|
|
221
|
+
captureLocal,
|
|
222
|
+
forkRecord,
|
|
223
|
+
forkBlock,
|
|
224
|
+
detectFork,
|
|
225
|
+
renderConflict,
|
|
226
|
+
adoptResolved,
|
|
227
|
+
stampProvenance,
|
|
228
|
+
attestHead,
|
|
229
|
+
fusionReason,
|
|
230
|
+
supports,
|
|
231
|
+
languageFor,
|
|
232
|
+
icrlib
|
|
233
|
+
} = ctx
|
|
234
|
+
|
|
235
|
+
return function reconcile(relPath, origin = 'local') {
|
|
236
|
+
if (SKIP.has(relPath) || isIgnored(relPath)) return
|
|
237
|
+
if (!canOpen(relPath)) return
|
|
238
|
+
const full = path.join(root, relPath)
|
|
239
|
+
const exists = fs.existsSync(full)
|
|
240
|
+
const disk = exists ? readText(full) : null
|
|
241
|
+
if (exists && disk === null) return
|
|
242
|
+
|
|
243
|
+
if (origin === 'local' && !canWrite(relPath)) {
|
|
244
|
+
const fe2 = fileDocs.get(relPath)
|
|
245
|
+
if (fe2 && fe2.synced) {
|
|
246
|
+
const dt = fe2.text.toString()
|
|
247
|
+
if (dt && dt !== disk) writeToDisk(relPath, dt)
|
|
248
|
+
}
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let fe = fileDocs.get(relPath)
|
|
253
|
+
if (!fe) { if (disk === null) return; openFile(relPath); return }
|
|
254
|
+
if (!fe.synced) return
|
|
255
|
+
if (touchFile) touchFile(relPath)
|
|
256
|
+
|
|
257
|
+
if (FORK_GATE && captureLocal) captureLocal(relPath)
|
|
258
|
+
|
|
259
|
+
if (FORK_GATE && fe.conflict && forkRecord) {
|
|
260
|
+
const rec = forkRecord(fe)
|
|
261
|
+
if (rec) {
|
|
262
|
+
const block = forkBlock(fe, rec)
|
|
263
|
+
if (origin === 'local') {
|
|
264
|
+
if (disk === block) return
|
|
265
|
+
const fork0 = forkBases.get(relPath)
|
|
266
|
+
const sawMarkers = fork0 === block || hasConflictMarkers(fork0 || '')
|
|
267
|
+
if (sawMarkers && disk !== null && !hasConflictMarkers(disk)) {
|
|
268
|
+
fe.doc.transact(() => {
|
|
269
|
+
applyDiff(fe.text, disk)
|
|
270
|
+
for (const v of rec.versions) fe.forkmark.set(v.hash, 1)
|
|
271
|
+
for (const key of [...fe.conflict.keys()]) fe.conflict.delete(key)
|
|
272
|
+
})
|
|
273
|
+
if (stampProvenance) stampProvenance(relPath, block || '', disk)
|
|
274
|
+
conflicted.delete(relPath)
|
|
275
|
+
known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
|
|
276
|
+
try { say(`✓ conflict in ${relPath} resolved by ${name}.`) } catch { }
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
if (!sawMarkers && disk !== null && disk !== fork0) {
|
|
280
|
+
if (detectFork) detectFork(relPath)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
if (renderConflict) renderConflict(relPath)
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
if (renderConflict) renderConflict(relPath)
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
if (disk !== null && hasConflictMarkers(disk) && !hasConflictMarkers(fe.text.toString())) {
|
|
290
|
+
if (adoptResolved) { adoptResolved(relPath); return }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const docText = fe.text.toString()
|
|
295
|
+
const noHistory = !bases.has(relPath) && !(priorShadow && priorShadow.has(relPath))
|
|
296
|
+
if (docText === '' && (!manifest.has(relPath) || (disk && noHistory))) {
|
|
297
|
+
if (disk === null) return
|
|
298
|
+
fe.doc.transact(() => applyDiff(fe.text, disk))
|
|
299
|
+
if (stampProvenance) stampProvenance(relPath, '', disk)
|
|
300
|
+
manifest.set(relPath, 1)
|
|
301
|
+
known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk)
|
|
302
|
+
if (shadow) shadow.set(relPath, disk)
|
|
303
|
+
try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
|
|
304
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'created' })
|
|
305
|
+
snappedBase.add(relPath)
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const yt = fe.text
|
|
310
|
+
if (disk === null) {
|
|
311
|
+
writeToDisk(relPath, docText)
|
|
312
|
+
bases.set(relPath, docText); forkBases.set(relPath, docText)
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (disk === docText) {
|
|
317
|
+
if (origin === 'local' && !known.has(relPath) && disk) markEditing(relPath)
|
|
318
|
+
known.add(relPath); bases.set(relPath, disk)
|
|
319
|
+
if (shadow) shadow.set(relPath, disk)
|
|
320
|
+
if (!forkBases.has(relPath) || origin === 'local') forkBases.set(relPath, disk)
|
|
321
|
+
if (!snappedBase.has(relPath) && disk) {
|
|
322
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'baseline' })
|
|
323
|
+
snappedBase.add(relPath)
|
|
324
|
+
}
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// FIRST-CONTACT ADOPT
|
|
329
|
+
if (!bases.has(relPath) && docText !== '' && disk !== docText) {
|
|
330
|
+
if (!preexisting.has(relPath)) {
|
|
331
|
+
const nl = (s) => (s === '' || s.endsWith('\n') ? s : s + '\n')
|
|
332
|
+
const r0 = icrMerge3('', nl(disk), nl(docText), relPath, icrlib)
|
|
333
|
+
if (r0.text !== docText) fe.doc.transact(() => applyDiff(yt, r0.text))
|
|
334
|
+
if (r0.text !== disk) writeToDisk(relPath, r0.text)
|
|
335
|
+
if (r0.conflict) conflicted.add(relPath)
|
|
336
|
+
bases.set(relPath, r0.text); forkBases.set(relPath, r0.text)
|
|
337
|
+
if (shadow) shadow.set(relPath, r0.text)
|
|
338
|
+
if (!manifest.has(relPath)) manifest.set(relPath, 1)
|
|
339
|
+
try { say(`⚠ ${relPath} was created by two participants at once — both versions were merged${r0.conflict ? ', and CONFLICT markers need resolving' : ''}.`) } catch { }
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
const anc = priorShadow ? priorShadow.get(relPath) : undefined
|
|
343
|
+
if (anc !== undefined && disk !== null) {
|
|
344
|
+
if (disk === anc) {
|
|
345
|
+
writeToDisk(relPath, docText)
|
|
346
|
+
bases.set(relPath, docText); forkBases.set(relPath, docText)
|
|
347
|
+
if (!snappedBase.has(relPath)) {
|
|
348
|
+
captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'room copy adopted on rejoin (local was unchanged)' })
|
|
349
|
+
snappedBase.add(relPath)
|
|
350
|
+
}
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy before rejoin merge' })
|
|
354
|
+
const rj = icrMerge3(anc, disk, docText, relPath, icrlib)
|
|
355
|
+
if (rj.text !== docText) fe.doc.transact(() => applyDiff(yt, rj.text))
|
|
356
|
+
if (rj.text !== disk) writeToDisk(relPath, rj.text)
|
|
357
|
+
if (rj.conflict) conflicted.add(relPath)
|
|
358
|
+
bases.set(relPath, rj.text); forkBases.set(relPath, rj.text)
|
|
359
|
+
if (shadow) shadow.set(relPath, rj.text)
|
|
360
|
+
if (!manifest.has(relPath)) manifest.set(relPath, 1)
|
|
361
|
+
try { say(`⚠ ${relPath} changed locally while ${name} was disconnected — the local edit and the room copy were merged${rj.conflict ? ', and CONFLICT markers need resolving' : ''}. Pre-merge local copy is in the file's history.`) } catch { }
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy at join (room copy adopted)' })
|
|
365
|
+
writeToDisk(relPath, docText)
|
|
366
|
+
bases.set(relPath, docText); forkBases.set(relPath, docText)
|
|
367
|
+
if (!snappedBase.has(relPath)) {
|
|
368
|
+
captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'adopted from room at join' })
|
|
369
|
+
snappedBase.add(relPath)
|
|
370
|
+
}
|
|
371
|
+
try { say(`↓ ${relPath} already existed in this room — ${name} adopted the room's copy. The local version before joining is saved in the file's history.`) } catch { }
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const base = bases.has(relPath) ? bases.get(relPath) : disk
|
|
376
|
+
const fork = forkBases.has(relPath) ? forkBases.get(relPath) : base
|
|
377
|
+
if (origin === 'local') { noteCoEditing(relPath); markEditing(relPath) }
|
|
378
|
+
|
|
379
|
+
let res, reAdded = false
|
|
380
|
+
if (origin === 'local' && hasConflictMarkers(docText) && !hasConflictMarkers(disk)) {
|
|
381
|
+
res = { text: disk, conflict: false }
|
|
382
|
+
} else if (origin === 'local') {
|
|
383
|
+
noteIfRewrite(relPath, fork, disk)
|
|
384
|
+
const sc = summarizeChange(fork, disk)
|
|
385
|
+
const isCleanRewrite = sc.isRewrite && supports && supports(relPath) && languageFor && languageFor(relPath).parses(disk)
|
|
386
|
+
const theirsNew = changedRange(fork.split('\n'), docText.split('\n')).newLines.filter((l) => l.length)
|
|
387
|
+
const mineLines = new Set(disk.split('\n'))
|
|
388
|
+
const integrated = theirsNew.every((l) => mineLines.has(l))
|
|
389
|
+
if (integrated || (isCleanRewrite && !hasConflictMarkers(docText))) {
|
|
390
|
+
res = { text: disk, conflict: false }
|
|
391
|
+
} else {
|
|
392
|
+
res = icrMerge3(fork, disk, docText, relPath, icrlib)
|
|
393
|
+
reAdded = !res.conflict && res.text !== disk
|
|
394
|
+
}
|
|
395
|
+
} else {
|
|
396
|
+
res = icrMerge3(base, disk, docText, relPath, icrlib)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (res && !res.conflict && fusionReason) {
|
|
400
|
+
const why = fusionReason(res.text, disk, docText, (supports && supports(relPath)) ? languageFor(relPath) : null)
|
|
401
|
+
if (why) {
|
|
402
|
+
conflicted.add(relPath)
|
|
403
|
+
if (icrWarned.get(relPath) !== why) {
|
|
404
|
+
icrWarned.set(relPath, why)
|
|
405
|
+
log(`[${name}] !! REFUSED a fused merge of ${relPath}: ${why}`)
|
|
406
|
+
try {
|
|
407
|
+
say(`[!] @all ${relPath}: a merge was REFUSED because it fused rather than merged — ${why}. Nothing was written: the room's copy and ${name}'s local copy are both intact and unchanged. Someone must reconcile them deliberately; a plain file write will merge against the other version again, hive_edit replaces it outright.`)
|
|
408
|
+
} catch { }
|
|
409
|
+
}
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (origin === 'local' && res.text !== docText) {
|
|
415
|
+
const sc = summarizeChange(docText, res.text)
|
|
416
|
+
captureSnapshot(relPath, docText, name, { force: sc.isRewrite, churn: `${sc.changedLines}/${sc.totalLines} lines` })
|
|
417
|
+
if (typeof lastLocalEdit === 'function') lastLocalEdit(relPath)
|
|
418
|
+
}
|
|
419
|
+
fe.doc.transact(() => applyDiff(yt, res.text))
|
|
420
|
+
if (origin === 'local') {
|
|
421
|
+
if (FORK_GATE) { if (res.text !== docText && attestHead) attestHead(relPath, res.text, docText) }
|
|
422
|
+
else if (res.text !== docText && stampProvenance) stampProvenance(relPath, docText, res.text)
|
|
423
|
+
}
|
|
424
|
+
if (res.text !== disk) writeToDisk(relPath, res.text)
|
|
425
|
+
known.add(relPath); bases.set(relPath, res.text)
|
|
426
|
+
if (origin === 'local') forkBases.set(relPath, res.text)
|
|
427
|
+
else if (!forkBases.get(relPath)) forkBases.set(relPath, res.text)
|
|
428
|
+
|
|
429
|
+
const hasMarkers = res.conflict || hasConflictMarkers(res.text)
|
|
430
|
+
if (hasMarkers && !conflicted.has(relPath)) {
|
|
431
|
+
conflicted.add(relPath)
|
|
432
|
+
log(`[${name}] ⚠ merge conflict in ${relPath} — kept BOTH versions with <<<<<<< markers`)
|
|
433
|
+
try { say(`⚠ MERGE CONFLICT in ${relPath} — it has <<<<<<< markers. Whoever owns it: resolve before continuing.`) } catch { }
|
|
434
|
+
} else if (!hasMarkers && conflicted.has(relPath)) {
|
|
435
|
+
conflicted.delete(relPath)
|
|
436
|
+
try { say(`✓ conflict in ${relPath} resolved.`) } catch { }
|
|
437
|
+
} else if (reAdded) {
|
|
438
|
+
log(`[${name}] protected ${relPath}: your edit was based on an older version — re-added changes that arrived since (nobody's work lost)`)
|
|
439
|
+
try { say(`↺ ${relPath}: ${name}'s edit was based on an older copy — kept the changes that landed in between (nothing lost).`) } catch { }
|
|
440
|
+
} else if (!hasMarkers && res.icr === 'rename') {
|
|
441
|
+
log(`[${name}] ICR merged ${relPath} (detected a rename and updated the call sites)`)
|
|
442
|
+
} else if (!hasMarkers && res.icr === 'structural') {
|
|
443
|
+
log(`[${name}] ICR merged ${relPath} structurally (both edits kept, syntax guaranteed)`)
|
|
444
|
+
} else if (!hasMarkers) {
|
|
445
|
+
log(`[${name}] merged ${relPath} (both edits kept)`)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (res.icrWarning && icrWarned.get(relPath) !== res.icrWarning) {
|
|
449
|
+
icrWarned.set(relPath, res.icrWarning)
|
|
450
|
+
log(`[${name}] ⚠ ICR flag in ${relPath} — ${res.icrWarning}`)
|
|
451
|
+
try { say(`⚠ ICR flag in ${relPath}: ${res.icrWarning}. It merged, but review — this is a break a plain merge can't see.`) } catch { }
|
|
452
|
+
} else if (!res.icrWarning && icrWarned.has(relPath)) {
|
|
453
|
+
icrWarned.delete(relPath)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (detectFork) detectFork(relPath)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// reconcile direct call
|
|
461
|
+
function reconcile(ctx, relPath, origin = 'local') {
|
|
462
|
+
return createReconciler(ctx)(relPath, origin)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
module.exports = {
|
|
466
|
+
reconcile,
|
|
467
|
+
createReconciler,
|
|
468
|
+
applyDiff,
|
|
469
|
+
hasConflictMarkers,
|
|
470
|
+
stripConflictMarkers,
|
|
471
|
+
changedRange,
|
|
472
|
+
symbolsIn,
|
|
473
|
+
summarizeChange,
|
|
474
|
+
merge3,
|
|
475
|
+
describeConflicts,
|
|
476
|
+
icrMerge3
|
|
477
|
+
}
|