hivecode-mcp 0.14.1 → 0.14.2

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.
Files changed (72) hide show
  1. package/HIVE_RULES.md +98 -98
  2. package/LICENSE +21 -21
  3. package/README.md +421 -421
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -310
  6. package/docs/GITHUB_WORKFLOW.md +394 -394
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -157
  9. package/docs/QUICKSTART.md +266 -266
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +474 -474
  12. package/docs/TROUBLESHOOTING.md +105 -105
  13. package/hive-activity.js +336 -336
  14. package/hive-authors.js +46 -44
  15. package/hive-await.js +230 -230
  16. package/hive-branch.js +608 -607
  17. package/hive-busy.js +88 -88
  18. package/hive-coord.js +110 -110
  19. package/hive-gate.js +135 -135
  20. package/hive-github.js +3 -1
  21. package/hive-hook-install.js +179 -179
  22. package/hive-instructions.js +74 -74
  23. package/hive-land.js +695 -691
  24. package/hive-listen.js +81 -81
  25. package/hive-mailbox.js +520 -520
  26. package/hive-mcp-http.js +591 -591
  27. package/hive-mcp.js +33 -6
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +351 -351
  30. package/hive-notify.js +234 -233
  31. package/hive-obligations.js +247 -247
  32. package/hive-poll-proto.js +50 -50
  33. package/hive-pr-staleness.js +189 -189
  34. package/hive-pr.js +600 -512
  35. package/hive-privacy.js +327 -327
  36. package/hive-receipts.js +233 -233
  37. package/hive-relay-health.js +234 -234
  38. package/hive-repos.js +430 -430
  39. package/hive-room-key.js +210 -210
  40. package/hive-room-mode.js +1668 -1668
  41. package/hive-room-watcher.js +562 -561
  42. package/hive-secrets.js +89 -89
  43. package/hive-session.js +114 -114
  44. package/hive-setup.js +560 -560
  45. package/hive-slack-agent.js +408 -408
  46. package/hive-slack.js +2384 -2367
  47. package/hive-stdio.js +172 -172
  48. package/hive-stop-hook.js +293 -293
  49. package/hive-throttle.js +118 -118
  50. package/hive-time.js +71 -71
  51. package/hive-transport.js +578 -578
  52. package/hive-update.js +58 -58
  53. package/hive-version.js +110 -110
  54. package/hive-webhook.js +55 -54
  55. package/icr-merge.js +58 -58
  56. package/icr.js +851 -851
  57. package/lang-brace.js +305 -305
  58. package/lang-js.js +651 -651
  59. package/lang-json.js +211 -211
  60. package/lang-python.js +588 -588
  61. package/lang-ruby.js +230 -230
  62. package/lang-toml.js +136 -136
  63. package/lang-yaml.js +183 -183
  64. package/limits.js +413 -413
  65. package/package.json +1 -1
  66. package/reconcile.cjs +621 -621
  67. package/slack-app-manifest-agent.json +61 -61
  68. package/slack-app-manifest-inbound.json +36 -36
  69. package/slack-app-manifest.json +27 -27
  70. package/substrate.js +252 -252
  71. package/sync.js +169 -11
  72. package/token.js +201 -201
package/lang-ruby.js CHANGED
@@ -1,230 +1,230 @@
1
- // Ruby language provider for ICR — keyword-nesting structural splitter (def/class/
2
- // module … end). Same honest-heuristic contract as the brace/python providers: not a
3
- // full parser, but conservative — anything it can't confidently structure falls back
4
- // to the engine's safe line tier, so output is never MORE broken than the inputs.
5
- //
6
- // Structure model: a line-oriented scan tracking strings, comments (# and =begin/=end),
7
- // bracket depth, and KEYWORD depth (def/class/module/if/while/… open; `end` closes).
8
- // Top-level def/class/module blocks become keyed units; the intent layer (dangling
9
- // refs, rename + call-site rewrite, token merge) rides on a lexer like Python's.
10
-
11
- function scannerAt(src) {
12
- const n = src.length
13
- return function at(i) {
14
- if (src[i] === '#') { let j = i + 1; while (j < n && src[j] !== '\n') j++; return j }
15
- if (src[i] === '"' || src[i] === "'") {
16
- const q = src[i]; let j = i + 1
17
- while (j < n) { if (src[j] === '\\') { j += 2; continue } if (src[j] === q) { j++; break } j++ }
18
- return j
19
- }
20
- return -1
21
- }
22
- }
23
-
24
- // Per physical line: the line's CODE text (strings/comments blanked), its bracket
25
- // delta, and byte range. =begin/=end block comments are whole-line constructs.
26
- function codeLines(src) {
27
- const at = scannerAt(src)
28
- const out = []
29
- let i = 0, inBlockComment = false
30
- const n = src.length
31
- while (i < n) {
32
- let e = src.indexOf('\n', i); e = e < 0 ? n : e + 1
33
- const raw = src.slice(i, e)
34
- if (inBlockComment) {
35
- if (/^=end\b/.test(raw)) inBlockComment = false
36
- out.push({ start: i, end: e, code: '', brackets: 0 })
37
- i = e; continue
38
- }
39
- if (/^=begin\b/.test(raw)) { inBlockComment = true; out.push({ start: i, end: e, code: '', brackets: 0 }); i = e; continue }
40
- let code = '', k = i, brackets = 0
41
- while (k < e) {
42
- const j = at(k)
43
- if (j > k) { if (src[k] !== '#') code += ' '; k = Math.min(e, j); continue }
44
- const c = src[k]
45
- if (c === '(' || c === '[' || c === '{') brackets++
46
- else if (c === ')' || c === ']' || c === '}') brackets--
47
- code += c; k++
48
- }
49
- out.push({ start: i, end: e, code, brackets })
50
- i = e
51
- }
52
- return out
53
- }
54
-
55
- // Keyword depth delta for one line of CODE. Conservative rules for the ambiguous
56
- // keywords (modifier `x if y` does not open; `while … do` opens once, not twice;
57
- // endless `def foo = expr` does not open).
58
- const OPENERS = new Set(['if', 'unless', 'while', 'until', 'case', 'begin', 'for'])
59
- function keywordDelta(code) {
60
- const words = code.match(/[A-Za-z_]\w*[?!]?/g) || []
61
- let d = 0
62
- const trimmed = code.trim()
63
- const endless = /\bdef\s+[A-Za-z_][\w.?!]*(\([^)]*\))?\s*=(?!=)/.test(code)
64
- const hasLoop = /\b(while|until|for)\b/.test(code)
65
- for (let w = 0; w < words.length; w++) {
66
- const word = words[w]
67
- if (word === 'def') { if (!endless) d++ }
68
- else if (word === 'class' || word === 'module') d++
69
- else if (OPENERS.has(word)) {
70
- // statement position only: first word of the line, or right after an assignment
71
- const isFirst = trimmed.startsWith(word)
72
- const afterAssign = new RegExp('=\\s*' + word + '\\b').test(code)
73
- if (word === 'while' || word === 'until' || word === 'for') { if (isFirst) d++ }
74
- else if (isFirst || afterAssign) d++
75
- }
76
- else if (word === 'do') { if (!hasLoop) d++ }
77
- else if (word === 'end') d--
78
- }
79
- return d
80
- }
81
-
82
- const HEAD = /^\s*(def)\s+(self\.)?([A-Za-z_][\w.?!]*)/
83
- const CONT = /^\s*(class|module)\s+(?:<<\s*self|([A-Z]\w*(?:::[A-Z]\w*)*))/
84
-
85
- function keyOf(text) {
86
- let m = text.match(HEAD); if (m) return 'def:' + (m[2] || '') + m[3]
87
- m = text.match(CONT); if (m) return m[1] + ':' + (m[2] || 'self')
88
- return 'stmt:' + text.split('\n')[0].trim().replace(/\s+/g, ' ')
89
- }
90
-
91
- // Units over a region: a def/class/module head at keyword-depth 0 owns everything
92
- // until its matching `end`; other depth-0 lines are statements (joined while
93
- // brackets stay open).
94
- function blocks(src) {
95
- const L = codeLines(src)
96
- const units = []
97
- let i = 0
98
- while (i < L.length) {
99
- while (i < L.length && L[i].code.trim() === '') i++
100
- if (i >= L.length) break
101
- const start = L[i].start
102
- const head = L[i].code
103
- if (/^\s*(def|class|module)\b/.test(head)) {
104
- let depth = 0, brackets = 0
105
- do {
106
- depth += keywordDelta(L[i].code)
107
- brackets += L[i].brackets
108
- i++
109
- } while (i < L.length && (depth > 0 || brackets > 0))
110
- units.push({ start, end: L[i - 1].end })
111
- } else {
112
- let brackets = L[i].brackets, depth = keywordDelta(L[i].code)
113
- i++
114
- while (i < L.length && (brackets > 0 || depth > 0)) { brackets += L[i].brackets; depth += keywordDelta(L[i].code); i++ }
115
- units.push({ start, end: L[i - 1].end })
116
- }
117
- }
118
- return units
119
- }
120
-
121
- function keyed(raw, src) {
122
- const seen = new Map(), out = []
123
- for (const u of raw) {
124
- const text = src.slice(u.start, u.end)
125
- let k = keyOf(text)
126
- const c = (seen.get(k) || 0) + 1; seen.set(k, c)
127
- if (c > 1) k += '#' + c
128
- out.push({ key: k, text, start: u.start, end: u.end })
129
- }
130
- return out
131
- }
132
-
133
- // Approximate parse: brackets balanced AND keyword nesting closes exactly
134
- // (never negative, ends at zero) AND no unterminated block comment.
135
- function wellNested(src) {
136
- const L = codeLines(src)
137
- let depth = 0, brackets = 0
138
- for (const l of L) {
139
- depth += keywordDelta(l.code)
140
- brackets += l.brackets
141
- if (depth < 0 || brackets < 0) return false
142
- }
143
- return depth === 0 && brackets === 0
144
- }
145
-
146
- // Lexer for the intent layer + token merge (comments in gaps, strings one token).
147
- function lexTokens(src) {
148
- const at = scannerAt(src)
149
- const out = []
150
- const n = src.length
151
- let i = 0
152
- while (i < n) {
153
- const c = src[i]
154
- if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { i++; continue }
155
- const j = at(i)
156
- if (j > i) {
157
- if (c === '#') { i = j; continue }
158
- out.push({ start: i, end: j, k: src.slice(i, j) }); i = j; continue
159
- }
160
- if (/[A-Za-z_]/.test(c)) { let e = i + 1; while (e < n && /\w/.test(src[e])) e++; if (src[e] === '?' || src[e] === '!') e++; out.push({ start: i, end: e, k: src.slice(i, e) }); i = e; continue }
161
- if (/[0-9]/.test(c)) { let e = i + 1; while (e < n && /[\w.]/.test(src[e])) e++; out.push({ start: i, end: e, k: src.slice(i, e) }); i = e; continue }
162
- out.push({ start: i, end: i + 1, k: c }); i++
163
- }
164
- return out
165
- }
166
-
167
- // Reference position: not `.name` (method call on receiver) — `:name` symbols DO
168
- // count (send(:helper) is a real reference; over-inclusion is the cautious direction).
169
- function isRefToken(src, t) {
170
- if (!/^[A-Za-z_]\w*[?!]?$/.test(t.k)) return false
171
- let p = t.start - 1
172
- while (p >= 0 && (src[p] === ' ' || src[p] === '\t')) p--
173
- return !(p >= 0 && src[p] === '.')
174
- }
175
-
176
- export const ruby = {
177
- id: 'ruby',
178
- exts: ['.rb', '.rake'],
179
- parses: (src) => wellNested(String(src == null ? '' : src)),
180
- parsesUnit: (src) => wellNested(String(src)),
181
- units: (src) => keyed(blocks(String(src)), String(src)),
182
- declaredNames: (src) => {
183
- const names = new Set()
184
- for (const u of keyed(blocks(String(src)), String(src))) {
185
- const m = u.key.match(/^(?:def|class|module):(?:self\.)?([A-Za-z_][\w.?!]*)/)
186
- if (m) names.add(m[1])
187
- }
188
- return names
189
- },
190
- usedIdentifiers: (src) => {
191
- src = String(src)
192
- const used = new Set()
193
- for (const t of lexTokens(src)) if (isRefToken(src, t)) used.add(t.k)
194
- return used
195
- },
196
- declBody: (src, name) => {
197
- src = String(src)
198
- for (const u of keyed(blocks(src), src)) {
199
- const m = u.key.match(/^(?:def|class|module):(?:self\.)?([A-Za-z_][\w.?!]*)/)
200
- if (!m || m[1] !== name) continue
201
- return u.text.replace(name, '').trimEnd()
202
- }
203
- return null
204
- },
205
- renameRefs: (src, oldName, newName) => {
206
- src = String(src)
207
- const spots = []
208
- for (const t of lexTokens(src)) if (t.k === oldName && isRefToken(src, t)) spots.push(t)
209
- let out = src
210
- for (const t of spots.reverse()) out = out.slice(0, t.start) + newName + out.slice(t.end)
211
- return out
212
- },
213
- tokenize: (src) => lexTokens(String(src)),
214
- // Descend into a class/module so two agents editing DIFFERENT methods merge.
215
- splitUnit: (text) => {
216
- const src = String(text)
217
- if (!/^\s*(class|module)\b/.test(src)) return null
218
- const L = codeLines(src)
219
- if (!L.length) return null
220
- const sigEnd = L[0].end
221
- // body = everything between the header line and the final matching `end` line
222
- let last = L.length - 1
223
- while (last > 0 && L[last].code.trim() === '') last--
224
- if (!/^\s*end\b/.test(L[last].code)) return null
225
- const bodyStart = sigEnd, bodyEnd = L[last].start
226
- const body = src.slice(bodyStart, bodyEnd)
227
- const inner = keyed(blocks(body), body)
228
- return { sig: src.slice(0, sigEnd), open: '', close: src.slice(bodyEnd), body, units: inner, join: '', spliceable: true }
229
- },
230
- }
1
+ // Ruby language provider for ICR — keyword-nesting structural splitter (def/class/
2
+ // module … end). Same honest-heuristic contract as the brace/python providers: not a
3
+ // full parser, but conservative — anything it can't confidently structure falls back
4
+ // to the engine's safe line tier, so output is never MORE broken than the inputs.
5
+ //
6
+ // Structure model: a line-oriented scan tracking strings, comments (# and =begin/=end),
7
+ // bracket depth, and KEYWORD depth (def/class/module/if/while/… open; `end` closes).
8
+ // Top-level def/class/module blocks become keyed units; the intent layer (dangling
9
+ // refs, rename + call-site rewrite, token merge) rides on a lexer like Python's.
10
+
11
+ function scannerAt(src) {
12
+ const n = src.length
13
+ return function at(i) {
14
+ if (src[i] === '#') { let j = i + 1; while (j < n && src[j] !== '\n') j++; return j }
15
+ if (src[i] === '"' || src[i] === "'") {
16
+ const q = src[i]; let j = i + 1
17
+ while (j < n) { if (src[j] === '\\') { j += 2; continue } if (src[j] === q) { j++; break } j++ }
18
+ return j
19
+ }
20
+ return -1
21
+ }
22
+ }
23
+
24
+ // Per physical line: the line's CODE text (strings/comments blanked), its bracket
25
+ // delta, and byte range. =begin/=end block comments are whole-line constructs.
26
+ function codeLines(src) {
27
+ const at = scannerAt(src)
28
+ const out = []
29
+ let i = 0, inBlockComment = false
30
+ const n = src.length
31
+ while (i < n) {
32
+ let e = src.indexOf('\n', i); e = e < 0 ? n : e + 1
33
+ const raw = src.slice(i, e)
34
+ if (inBlockComment) {
35
+ if (/^=end\b/.test(raw)) inBlockComment = false
36
+ out.push({ start: i, end: e, code: '', brackets: 0 })
37
+ i = e; continue
38
+ }
39
+ if (/^=begin\b/.test(raw)) { inBlockComment = true; out.push({ start: i, end: e, code: '', brackets: 0 }); i = e; continue }
40
+ let code = '', k = i, brackets = 0
41
+ while (k < e) {
42
+ const j = at(k)
43
+ if (j > k) { if (src[k] !== '#') code += ' '; k = Math.min(e, j); continue }
44
+ const c = src[k]
45
+ if (c === '(' || c === '[' || c === '{') brackets++
46
+ else if (c === ')' || c === ']' || c === '}') brackets--
47
+ code += c; k++
48
+ }
49
+ out.push({ start: i, end: e, code, brackets })
50
+ i = e
51
+ }
52
+ return out
53
+ }
54
+
55
+ // Keyword depth delta for one line of CODE. Conservative rules for the ambiguous
56
+ // keywords (modifier `x if y` does not open; `while … do` opens once, not twice;
57
+ // endless `def foo = expr` does not open).
58
+ const OPENERS = new Set(['if', 'unless', 'while', 'until', 'case', 'begin', 'for'])
59
+ function keywordDelta(code) {
60
+ const words = code.match(/[A-Za-z_]\w*[?!]?/g) || []
61
+ let d = 0
62
+ const trimmed = code.trim()
63
+ const endless = /\bdef\s+[A-Za-z_][\w.?!]*(\([^)]*\))?\s*=(?!=)/.test(code)
64
+ const hasLoop = /\b(while|until|for)\b/.test(code)
65
+ for (let w = 0; w < words.length; w++) {
66
+ const word = words[w]
67
+ if (word === 'def') { if (!endless) d++ }
68
+ else if (word === 'class' || word === 'module') d++
69
+ else if (OPENERS.has(word)) {
70
+ // statement position only: first word of the line, or right after an assignment
71
+ const isFirst = trimmed.startsWith(word)
72
+ const afterAssign = new RegExp('=\\s*' + word + '\\b').test(code)
73
+ if (word === 'while' || word === 'until' || word === 'for') { if (isFirst) d++ }
74
+ else if (isFirst || afterAssign) d++
75
+ }
76
+ else if (word === 'do') { if (!hasLoop) d++ }
77
+ else if (word === 'end') d--
78
+ }
79
+ return d
80
+ }
81
+
82
+ const HEAD = /^\s*(def)\s+(self\.)?([A-Za-z_][\w.?!]*)/
83
+ const CONT = /^\s*(class|module)\s+(?:<<\s*self|([A-Z]\w*(?:::[A-Z]\w*)*))/
84
+
85
+ function keyOf(text) {
86
+ let m = text.match(HEAD); if (m) return 'def:' + (m[2] || '') + m[3]
87
+ m = text.match(CONT); if (m) return m[1] + ':' + (m[2] || 'self')
88
+ return 'stmt:' + text.split('\n')[0].trim().replace(/\s+/g, ' ')
89
+ }
90
+
91
+ // Units over a region: a def/class/module head at keyword-depth 0 owns everything
92
+ // until its matching `end`; other depth-0 lines are statements (joined while
93
+ // brackets stay open).
94
+ function blocks(src) {
95
+ const L = codeLines(src)
96
+ const units = []
97
+ let i = 0
98
+ while (i < L.length) {
99
+ while (i < L.length && L[i].code.trim() === '') i++
100
+ if (i >= L.length) break
101
+ const start = L[i].start
102
+ const head = L[i].code
103
+ if (/^\s*(def|class|module)\b/.test(head)) {
104
+ let depth = 0, brackets = 0
105
+ do {
106
+ depth += keywordDelta(L[i].code)
107
+ brackets += L[i].brackets
108
+ i++
109
+ } while (i < L.length && (depth > 0 || brackets > 0))
110
+ units.push({ start, end: L[i - 1].end })
111
+ } else {
112
+ let brackets = L[i].brackets, depth = keywordDelta(L[i].code)
113
+ i++
114
+ while (i < L.length && (brackets > 0 || depth > 0)) { brackets += L[i].brackets; depth += keywordDelta(L[i].code); i++ }
115
+ units.push({ start, end: L[i - 1].end })
116
+ }
117
+ }
118
+ return units
119
+ }
120
+
121
+ function keyed(raw, src) {
122
+ const seen = new Map(), out = []
123
+ for (const u of raw) {
124
+ const text = src.slice(u.start, u.end)
125
+ let k = keyOf(text)
126
+ const c = (seen.get(k) || 0) + 1; seen.set(k, c)
127
+ if (c > 1) k += '#' + c
128
+ out.push({ key: k, text, start: u.start, end: u.end })
129
+ }
130
+ return out
131
+ }
132
+
133
+ // Approximate parse: brackets balanced AND keyword nesting closes exactly
134
+ // (never negative, ends at zero) AND no unterminated block comment.
135
+ function wellNested(src) {
136
+ const L = codeLines(src)
137
+ let depth = 0, brackets = 0
138
+ for (const l of L) {
139
+ depth += keywordDelta(l.code)
140
+ brackets += l.brackets
141
+ if (depth < 0 || brackets < 0) return false
142
+ }
143
+ return depth === 0 && brackets === 0
144
+ }
145
+
146
+ // Lexer for the intent layer + token merge (comments in gaps, strings one token).
147
+ function lexTokens(src) {
148
+ const at = scannerAt(src)
149
+ const out = []
150
+ const n = src.length
151
+ let i = 0
152
+ while (i < n) {
153
+ const c = src[i]
154
+ if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { i++; continue }
155
+ const j = at(i)
156
+ if (j > i) {
157
+ if (c === '#') { i = j; continue }
158
+ out.push({ start: i, end: j, k: src.slice(i, j) }); i = j; continue
159
+ }
160
+ if (/[A-Za-z_]/.test(c)) { let e = i + 1; while (e < n && /\w/.test(src[e])) e++; if (src[e] === '?' || src[e] === '!') e++; out.push({ start: i, end: e, k: src.slice(i, e) }); i = e; continue }
161
+ if (/[0-9]/.test(c)) { let e = i + 1; while (e < n && /[\w.]/.test(src[e])) e++; out.push({ start: i, end: e, k: src.slice(i, e) }); i = e; continue }
162
+ out.push({ start: i, end: i + 1, k: c }); i++
163
+ }
164
+ return out
165
+ }
166
+
167
+ // Reference position: not `.name` (method call on receiver) — `:name` symbols DO
168
+ // count (send(:helper) is a real reference; over-inclusion is the cautious direction).
169
+ function isRefToken(src, t) {
170
+ if (!/^[A-Za-z_]\w*[?!]?$/.test(t.k)) return false
171
+ let p = t.start - 1
172
+ while (p >= 0 && (src[p] === ' ' || src[p] === '\t')) p--
173
+ return !(p >= 0 && src[p] === '.')
174
+ }
175
+
176
+ export const ruby = {
177
+ id: 'ruby',
178
+ exts: ['.rb', '.rake'],
179
+ parses: (src) => wellNested(String(src == null ? '' : src)),
180
+ parsesUnit: (src) => wellNested(String(src)),
181
+ units: (src) => keyed(blocks(String(src)), String(src)),
182
+ declaredNames: (src) => {
183
+ const names = new Set()
184
+ for (const u of keyed(blocks(String(src)), String(src))) {
185
+ const m = u.key.match(/^(?:def|class|module):(?:self\.)?([A-Za-z_][\w.?!]*)/)
186
+ if (m) names.add(m[1])
187
+ }
188
+ return names
189
+ },
190
+ usedIdentifiers: (src) => {
191
+ src = String(src)
192
+ const used = new Set()
193
+ for (const t of lexTokens(src)) if (isRefToken(src, t)) used.add(t.k)
194
+ return used
195
+ },
196
+ declBody: (src, name) => {
197
+ src = String(src)
198
+ for (const u of keyed(blocks(src), src)) {
199
+ const m = u.key.match(/^(?:def|class|module):(?:self\.)?([A-Za-z_][\w.?!]*)/)
200
+ if (!m || m[1] !== name) continue
201
+ return u.text.replace(name, '').trimEnd()
202
+ }
203
+ return null
204
+ },
205
+ renameRefs: (src, oldName, newName) => {
206
+ src = String(src)
207
+ const spots = []
208
+ for (const t of lexTokens(src)) if (t.k === oldName && isRefToken(src, t)) spots.push(t)
209
+ let out = src
210
+ for (const t of spots.reverse()) out = out.slice(0, t.start) + newName + out.slice(t.end)
211
+ return out
212
+ },
213
+ tokenize: (src) => lexTokens(String(src)),
214
+ // Descend into a class/module so two agents editing DIFFERENT methods merge.
215
+ splitUnit: (text) => {
216
+ const src = String(text)
217
+ if (!/^\s*(class|module)\b/.test(src)) return null
218
+ const L = codeLines(src)
219
+ if (!L.length) return null
220
+ const sigEnd = L[0].end
221
+ // body = everything between the header line and the final matching `end` line
222
+ let last = L.length - 1
223
+ while (last > 0 && L[last].code.trim() === '') last--
224
+ if (!/^\s*end\b/.test(L[last].code)) return null
225
+ const bodyStart = sigEnd, bodyEnd = L[last].start
226
+ const body = src.slice(bodyStart, bodyEnd)
227
+ const inner = keyed(blocks(body), body)
228
+ return { sig: src.slice(0, sigEnd), open: '', close: src.slice(bodyEnd), body, units: inner, join: '', spliceable: true }
229
+ },
230
+ }