hivecode-mcp 0.14.0 → 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 -450
  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 +2004 -1944
  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 -580
  27. package/hive-mcp.js +2156 -2093
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +351 -350
  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 -230
  38. package/hive-repos.js +430 -430
  39. package/hive-room-key.js +210 -209
  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 -558
  45. package/hive-slack-agent.js +408 -396
  46. package/hive-slack.js +2384 -1809
  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 -353
  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 +192 -192
  66. package/reconcile.cjs +621 -614
  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 +5189 -4994
  72. package/token.js +201 -201
package/hive-repos.js CHANGED
@@ -1,430 +1,430 @@
1
- // hive-repos.js — Per-machine repo registry (~/.hivecode/repos.json),
2
- // display-only room repo_holder record, and the hive_pr MCP handler (Part A2).
3
- //
4
- // Security invariant:
5
- // What lands, and from where, is decided strictly on the local machine via
6
- // ~/.hivecode/repos.json + git config verification. Room data (repo_holder)
7
- // is display-only ({ branch, holder, host }) and never supplies a local path.
8
- // Test commands and staleness overrides (force) are never accepted from MCP
9
- // tool arguments; tests come only from testCommandFor(root, branch) in hive-land.js.
10
-
11
- import fs from 'fs';
12
- import os from 'os';
13
- import path from 'path';
14
- import { parseLink } from './sync.js';
15
- import * as HB from './hive-branch.js';
16
- import { landAndOpen, testCommandFor } from './hive-land.js';
17
-
18
- export function reposFile() {
19
- return path.join(HB.hiveHome(), 'repos.json');
20
- }
21
-
22
- export function loadRepos() {
23
- try {
24
- const raw = fs.readFileSync(reposFile(), 'utf8');
25
- const parsed = JSON.parse(raw);
26
- if (parsed && typeof parsed === 'object' && parsed.rooms && typeof parsed.rooms === 'object') {
27
- return parsed;
28
- }
29
- } catch {}
30
- return { rooms: {} };
31
- }
32
-
33
- function normalizeEntries(val) {
34
- if (!val) return [];
35
- if (Array.isArray(val)) return val.filter(e => e && typeof e === 'object' && e.root && e.branch);
36
- if (typeof val === 'object' && val.root && val.branch) return [val];
37
- return [];
38
- }
39
-
40
- export function saveRepos(data) {
41
- try {
42
- const file = reposFile();
43
- fs.mkdirSync(path.dirname(file), { recursive: true });
44
- const payload = JSON.stringify(data, null, 2);
45
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
46
- fs.writeFileSync(tmp, payload);
47
- try {
48
- fs.renameSync(tmp, file);
49
- } catch {
50
- try { fs.writeFileSync(file, payload); } catch {}
51
- try { fs.unlinkSync(tmp); } catch {}
52
- }
53
- return true;
54
- } catch {
55
- return false;
56
- }
57
- }
58
-
59
- /**
60
- * Register a local git repository + branch binding for a room on this machine.
61
- * Never throws: a registry write failure must never fail bindBranch or hive_join.
62
- *
63
- * Accepts { room, root, cwd, branch, link }.
64
- */
65
- export function registerRepo(opts = {}) {
66
- try {
67
- const rawRoot = opts.root || opts.cwd || '';
68
- const branch = opts.branch || '';
69
- const link = opts.link || '';
70
- const room = opts.room || (link ? parseLink(link).room : '');
71
- if (!rawRoot || !branch || !room) return false;
72
-
73
- const root = path.resolve(rawRoot);
74
- const data = loadRepos();
75
- const list = normalizeEntries(data.rooms[room]);
76
- const entry = {
77
- root,
78
- branch,
79
- link,
80
- updatedAt: new Date().toISOString(),
81
- };
82
- const idx = list.findIndex(e => path.resolve(e.root) === root);
83
- if (idx >= 0) list[idx] = entry;
84
- else list.push(entry);
85
- data.rooms[room] = list;
86
- return saveRepos(data);
87
- } catch {
88
- return false;
89
- }
90
- }
91
-
92
- function isEntryValidForRoom(entry, roomId) {
93
- try {
94
- if (!entry || !entry.root || !entry.branch) return false;
95
- const root = path.resolve(entry.root);
96
- if (!fs.existsSync(root) || !HB.repoRoot(root)) return false;
97
- const binding = HB.readBinding(root, entry.branch);
98
- if (!binding || !binding.link) return false;
99
- const boundRoom = parseLink(binding.link).room;
100
- return boundRoom === roomId;
101
- } catch {
102
- return false;
103
- }
104
- }
105
-
106
- /**
107
- * Look up the local repository bound to `room` on this machine.
108
- * Verifies every candidate on disk against git config (`hivecode.<branch>.room`).
109
- *
110
- * Error codes:
111
- * - Q583: no entry registered for this room on this machine
112
- * - Q584: registered entry is stale (moved/deleted/rebound) OR multiple clones
113
- * on this machine are bound to the same room and no preferredRoot matched
114
- */
115
- export function lookupRepo(roomOrLink, opts = {}) {
116
- const roomId = parseLink(String(roomOrLink || '')).room || String(roomOrLink || '').trim();
117
- if (!roomId) {
118
- return {
119
- ok: false,
120
- code: 'Q583',
121
- notFound: true,
122
- entries: [],
123
- why: 'Q583 — room identifier is missing; cannot resolve a local git repository.',
124
- };
125
- }
126
-
127
- const data = loadRepos();
128
- const rawList = normalizeEntries(data.rooms[roomId]);
129
- const validEntries = [];
130
- const staleEntries = [];
131
-
132
- for (const e of rawList) {
133
- const normalized = { ...e, root: path.resolve(e.root) };
134
- if (isEntryValidForRoom(normalized, roomId)) {
135
- validEntries.push(normalized);
136
- } else {
137
- staleEntries.push(normalized);
138
- }
139
- }
140
-
141
- // If the current session joined by branch, also check its own branchCtx directly
142
- if (opts.preferredRoot) {
143
- const prefRoot = path.resolve(opts.preferredRoot);
144
- const alreadyInValid = validEntries.find(e => path.resolve(e.root) === prefRoot);
145
- if (alreadyInValid) {
146
- return { ok: true, entry: alreadyInValid };
147
- }
148
- if (opts.preferredBranch) {
149
- const candidate = { root: prefRoot, branch: opts.preferredBranch, link: opts.preferredLink || '' };
150
- if (isEntryValidForRoom(candidate, roomId)) {
151
- return { ok: true, entry: candidate };
152
- }
153
- }
154
- }
155
-
156
- if (validEntries.length === 1) {
157
- return { ok: true, entry: validEntries[0] };
158
- }
159
-
160
- if (validEntries.length > 1) {
161
- return {
162
- ok: false,
163
- code: 'Q584',
164
- ambiguous: true,
165
- entries: validEntries,
166
- why:
167
- `Q584 — ${validEntries.length} repositories on this machine are bound to room ${roomId}:\n` +
168
- validEntries.map(e => ` - ${e.root} (branch ${e.branch})`).join('\n') +
169
- `\nRefusing to pick one silently. Join by branch from the clone you want to land from (hive_join with branch + repo), or unbind the extra clone.`,
170
- };
171
- }
172
-
173
- if (staleEntries.length > 0) {
174
- return {
175
- ok: false,
176
- code: 'Q584',
177
- stale: true,
178
- entries: [],
179
- why:
180
- `Q584 — the registered repository for room ${roomId} on this machine (${staleEntries.map(e => `${e.root}#${e.branch}`).join(', ')}) ` +
181
- `is no longer bound to this room or was moved/deleted. Re-bind with: npx -y hivecode-mcp@latest branch <branch> --join "<link>"`,
182
- };
183
- }
184
-
185
- return {
186
- ok: false,
187
- code: 'Q583',
188
- notFound: true,
189
- entries: [],
190
- why: `Q583 — room ${roomId} is not bound to a git branch on this machine.`,
191
- };
192
- }
193
-
194
- const ALLOWED_HOLDER_KEYS = ['branch', 'holder', 'host', 'updatedAt'];
195
-
196
- /**
197
- * Write a display-only repo_holder record into the room Y.Doc.
198
- * Strips all filesystem paths (root, cwd, dir, path). Never throws.
199
- */
200
- export function writeRepoHolder(doc, info = {}) {
201
- try {
202
- if (!doc || typeof doc.getMap !== 'function') return false;
203
- const map = doc.getMap('repo_holder');
204
- const branch = typeof info.branch === 'string' ? info.branch.trim() : '';
205
- const holder = typeof (info.holder || info.name) === 'string' ? String(info.holder || info.name).trim() : '';
206
- const host = typeof info.host === 'string' && info.host.trim() ? info.host.trim() : os.hostname();
207
- const updatedAt = new Date().toISOString();
208
-
209
- const apply = () => {
210
- for (const k of Array.from(map.keys())) {
211
- if (!ALLOWED_HOLDER_KEYS.includes(k)) map.delete(k);
212
- }
213
- if (branch) map.set('branch', branch);
214
- if (holder) map.set('holder', holder);
215
- if (host) map.set('host', host);
216
- map.set('updatedAt', updatedAt);
217
- };
218
- if (typeof doc.transact === 'function') doc.transact(apply);
219
- else apply();
220
- return true;
221
- } catch {
222
- return false;
223
- }
224
- }
225
-
226
- /**
227
- * Read the display-only repo_holder record from the room Y.Doc.
228
- * Returns only { branch, holder, host, updatedAt } (never any path field), or null.
229
- */
230
- export function readRepoHolder(doc) {
231
- try {
232
- if (!doc || typeof doc.getMap !== 'function') return null;
233
- const map = doc.getMap('repo_holder');
234
- const branch = typeof map.get('branch') === 'string' ? map.get('branch') : '';
235
- const holder = typeof map.get('holder') === 'string' ? map.get('holder') : '';
236
- const host = typeof map.get('host') === 'string' ? map.get('host') : '';
237
- const updatedAt = typeof map.get('updatedAt') === 'string' ? map.get('updatedAt') : '';
238
- if (!branch && !holder && !host) return null;
239
- const out = { branch, holder, host };
240
- if (updatedAt) out.updatedAt = updatedAt;
241
- return out;
242
- } catch {
243
- return null;
244
- }
245
- }
246
-
247
- /**
248
- * Factory for the hive_pr MCP tool handler.
249
- * Accepts an injectable `land` function (defaults to `landAndOpen` from hive-land.js).
250
- */
251
- export function makeHivePr({
252
- land = landAndOpen,
253
- getSession,
254
- lookup = lookupRepo,
255
- readHolder = readRepoHolder,
256
- register = registerRepo,
257
- writeHolder = writeRepoHolder,
258
- } = {}) {
259
- return async function hivePr(args = {}) {
260
- const s = typeof getSession === 'function' ? getSession() : null;
261
- if (!s || !s.hive) {
262
- return 'Q585 — not in a room. Call hive_join first.';
263
- }
264
-
265
- const st = typeof s.hive.status === 'function' ? s.hive.status() : {};
266
- if (st && st.connected === false) {
267
- return 'Q586 — room connection is currently offline; wait for reconnect before running hive_pr.';
268
- }
269
-
270
- const linkStr = s.inviteLink || (s.relay && s.room ? `${s.relay}|${s.room}` : '');
271
- const roomId = s.room || parseLink(linkStr).room;
272
- const found = lookup(roomId, {
273
- preferredRoot: s.branchCtx?.root,
274
- preferredBranch: s.branchCtx?.branch,
275
- preferredLink: linkStr,
276
- });
277
-
278
- if (!found || !found.ok) {
279
- const holder = readHolder(s.hive.doc);
280
- if (found?.code === 'Q584') {
281
- const extra = holder && (holder.holder || holder.host)
282
- ? `\nRoom repo holder record: ${holder.holder || 'unknown'} (${holder.host || 'unknown'}) on branch \`${holder.branch || 'unknown'}\`.`
283
- : '';
284
- return `${found.why}${extra}`;
285
- }
286
- if (holder && (holder.holder || holder.host)) {
287
- const who = holder.holder || 'another member';
288
- const where = holder.host || 'another machine';
289
- const br = holder.branch || 'unknown';
290
- return (
291
- `Q582 — this room's repository is not on this machine. ` +
292
- `The repo for branch \`${br}\` is on ${who} (${where}). ` +
293
- `Ask @${who} to run hive_pr, or run it on ${where}.`
294
- );
295
- }
296
- return (
297
- found?.why ||
298
- `Q583 — room ${roomId || '(unknown)'} is not bound to a git branch on this machine. ` +
299
- `Bind a repository first with: npx -y hivecode-mcp@latest branch <branch> --join "${linkStr || '<link>'}"`
300
- );
301
- }
302
-
303
- // Strict boolean check: only `confirm: true` lands/pushes/opens a PR.
304
- // Security: `force`, `test`, and `testMinutes` are never accepted from MCP callers;
305
- // test command comes from testCommandFor(root, branch) in hive-land.js (saved git config or branch-tip package.json).
306
- const confirmed = args?.confirm === true;
307
- const by = s.owner ? `${s.name} for ${s.owner}` : s.name;
308
- const configuredTest = testCommandFor(found.entry.root, found.entry.branch).command;
309
-
310
- const res = await land({
311
- cwd: found.entry.root,
312
- branch: found.entry.branch,
313
- dry: !confirmed,
314
- push: confirmed,
315
- pr: confirmed,
316
- base: args?.base,
317
- test: configuredTest,
318
- title: args?.title,
319
- summary: args?.summary,
320
- by,
321
- allowWorkflowChanges: args?.allowWorkflowChanges === true,
322
- only: args?.only,
323
- });
324
-
325
- const lines = [];
326
-
327
- if (!res || !res.ok) {
328
- if (res?.why) {
329
- lines.push(res.why);
330
- } else {
331
- lines.push(`hive_pr refused${res?.code ? ` (${res.code})` : ''}.`);
332
- }
333
-
334
- // Check if caller holds claims itself on Q575
335
- const holders = Array.isArray(res?.holders) ? res.holders : [];
336
- const selfHeld = holders.filter(h => h && h.by === s.name).map(h => h.file);
337
- const otherHeld = holders.filter(h => h && h.by !== s.name);
338
- if (selfHeld.length > 0) {
339
- for (const f of selfHeld) {
340
- lines.push(`Note: you hold ${f}, release it first (call hive_release).`);
341
- }
342
- }
343
- if (otherHeld.length > 0) {
344
- lines.push(
345
- `Held by teammates: ` +
346
- otherHeld.map(h => `${h.file} (by ${h.by}${h.intent ? `: ${h.intent}` : ''})`).join(', ')
347
- );
348
- }
349
-
350
- if (Array.isArray(res?.orphans) && res.orphans.length > 0) {
351
- lines.push(
352
- `Orphan files (changed in room with no verified receipt — will NOT land): ` +
353
- res.orphans.join(', ')
354
- );
355
- }
356
- return lines.join('\n');
357
- }
358
-
359
- // Success (either dry-run or confirmed landing)
360
- if (!confirmed) {
361
- lines.push(`DRY RUN — hive_pr plan for branch \`${found.entry.branch}\` in ${found.entry.root}:`);
362
- if (res.why) lines.push(res.why);
363
- const plan = Array.isArray(res.plan) ? res.plan : [];
364
- if (plan.length === 0) {
365
- if (!res.why) {
366
- lines.push(' (no receipt-verified file changes to land)');
367
- }
368
- } else {
369
- for (const group of plan) {
370
- const files = Array.isArray(group.files) ? group.files.join(', ') : '';
371
- const intents = Array.isArray(group.intents) && group.intents.length ? ` — ${group.intents.join('; ')}` : '';
372
- lines.push(` • ${group.agent || 'agent'}: ${files}${intents}`);
373
- }
374
- }
375
- if (Array.isArray(res.deletions) && res.deletions.length > 0) {
376
- lines.push(` • Deletions: ${res.deletions.join(', ')}`);
377
- }
378
- if (Array.isArray(res.orphans) && res.orphans.length > 0) {
379
- lines.push(
380
- `⚠️ Orphans (changed in room with no verified receipt — will NOT land): ` +
381
- res.orphans.join(', ')
382
- );
383
- }
384
- lines.push(`\nNothing was committed or pushed. Call hive_pr with confirm: true to land, push, and open/update the PR.`);
385
- return lines.join('\n');
386
- }
387
-
388
- // Confirmed landing succeeded: refresh local registry + room holder record
389
- register({
390
- room: roomId,
391
- root: found.entry.root,
392
- branch: found.entry.branch,
393
- link: linkStr || found.entry.link,
394
- });
395
- writeHolder(s.hive.doc, {
396
- branch: found.entry.branch,
397
- holder: s.name,
398
- host: os.hostname(),
399
- });
400
-
401
- lines.push(`LANDED on branch \`${found.entry.branch}\`${res.tip ? ` (tip ${res.tip})` : ''}.`);
402
- if (res.why) lines.push(res.why);
403
- const plan = Array.isArray(res.plan) ? res.plan : [];
404
- for (const group of plan) {
405
- const files = Array.isArray(group.files) ? group.files.join(', ') : '';
406
- const intents = Array.isArray(group.intents) && group.intents.length ? ` — ${group.intents.join('; ')}` : '';
407
- lines.push(` • ${group.agent || 'agent'}: ${files}${intents}`);
408
- }
409
- if (res.tests?.ran) {
410
- lines.push(` • Tests: ${res.tests.command} -> ${res.tests.ok ? 'PASS' : 'FAIL'} (${res.tests.seconds ?? 0}s)`);
411
- }
412
- if (Array.isArray(res.orphans) && res.orphans.length > 0) {
413
- lines.push(
414
- `⚠️ Orphans (changed in room with no verified receipt — did NOT land): ` +
415
- res.orphans.join(', ')
416
- );
417
- }
418
- if (res.pr?.url) {
419
- const action = res.pr.created ? 'opened' : 'updated';
420
- lines.push(` • PR #${res.pr.number || ''} ${action}: ${res.pr.url}`);
421
- try {
422
- s.hive.say(
423
- `PR ${action} for branch \`${found.entry.branch}\`: ${res.pr.url}` +
424
- (res.tip ? ` (tip ${res.tip})` : '')
425
- );
426
- } catch {}
427
- }
428
- return lines.join('\n');
429
- };
430
- }
1
+ // hive-repos.js — Per-machine repo registry (~/.hivecode/repos.json),
2
+ // display-only room repo_holder record, and the hive_pr MCP handler (Part A2).
3
+ //
4
+ // Security invariant:
5
+ // What lands, and from where, is decided strictly on the local machine via
6
+ // ~/.hivecode/repos.json + git config verification. Room data (repo_holder)
7
+ // is display-only ({ branch, holder, host }) and never supplies a local path.
8
+ // Test commands and staleness overrides (force) are never accepted from MCP
9
+ // tool arguments; tests come only from testCommandFor(root, branch) in hive-land.js.
10
+
11
+ import fs from 'fs';
12
+ import os from 'os';
13
+ import path from 'path';
14
+ import { parseLink } from './sync.js';
15
+ import * as HB from './hive-branch.js';
16
+ import { landAndOpen, testCommandFor } from './hive-land.js';
17
+
18
+ export function reposFile() {
19
+ return path.join(HB.hiveHome(), 'repos.json');
20
+ }
21
+
22
+ export function loadRepos() {
23
+ try {
24
+ const raw = fs.readFileSync(reposFile(), 'utf8');
25
+ const parsed = JSON.parse(raw);
26
+ if (parsed && typeof parsed === 'object' && parsed.rooms && typeof parsed.rooms === 'object') {
27
+ return parsed;
28
+ }
29
+ } catch {}
30
+ return { rooms: {} };
31
+ }
32
+
33
+ function normalizeEntries(val) {
34
+ if (!val) return [];
35
+ if (Array.isArray(val)) return val.filter(e => e && typeof e === 'object' && e.root && e.branch);
36
+ if (typeof val === 'object' && val.root && val.branch) return [val];
37
+ return [];
38
+ }
39
+
40
+ export function saveRepos(data) {
41
+ try {
42
+ const file = reposFile();
43
+ fs.mkdirSync(path.dirname(file), { recursive: true });
44
+ const payload = JSON.stringify(data, null, 2);
45
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
46
+ fs.writeFileSync(tmp, payload);
47
+ try {
48
+ fs.renameSync(tmp, file);
49
+ } catch {
50
+ try { fs.writeFileSync(file, payload); } catch {}
51
+ try { fs.unlinkSync(tmp); } catch {}
52
+ }
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Register a local git repository + branch binding for a room on this machine.
61
+ * Never throws: a registry write failure must never fail bindBranch or hive_join.
62
+ *
63
+ * Accepts { room, root, cwd, branch, link }.
64
+ */
65
+ export function registerRepo(opts = {}) {
66
+ try {
67
+ const rawRoot = opts.root || opts.cwd || '';
68
+ const branch = opts.branch || '';
69
+ const link = opts.link || '';
70
+ const room = opts.room || (link ? parseLink(link).room : '');
71
+ if (!rawRoot || !branch || !room) return false;
72
+
73
+ const root = path.resolve(rawRoot);
74
+ const data = loadRepos();
75
+ const list = normalizeEntries(data.rooms[room]);
76
+ const entry = {
77
+ root,
78
+ branch,
79
+ link,
80
+ updatedAt: new Date().toISOString(),
81
+ };
82
+ const idx = list.findIndex(e => path.resolve(e.root) === root);
83
+ if (idx >= 0) list[idx] = entry;
84
+ else list.push(entry);
85
+ data.rooms[room] = list;
86
+ return saveRepos(data);
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ function isEntryValidForRoom(entry, roomId) {
93
+ try {
94
+ if (!entry || !entry.root || !entry.branch) return false;
95
+ const root = path.resolve(entry.root);
96
+ if (!fs.existsSync(root) || !HB.repoRoot(root)) return false;
97
+ const binding = HB.readBinding(root, entry.branch);
98
+ if (!binding || !binding.link) return false;
99
+ const boundRoom = parseLink(binding.link).room;
100
+ return boundRoom === roomId;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Look up the local repository bound to `room` on this machine.
108
+ * Verifies every candidate on disk against git config (`hivecode.<branch>.room`).
109
+ *
110
+ * Error codes:
111
+ * - Q583: no entry registered for this room on this machine
112
+ * - Q584: registered entry is stale (moved/deleted/rebound) OR multiple clones
113
+ * on this machine are bound to the same room and no preferredRoot matched
114
+ */
115
+ export function lookupRepo(roomOrLink, opts = {}) {
116
+ const roomId = parseLink(String(roomOrLink || '')).room || String(roomOrLink || '').trim();
117
+ if (!roomId) {
118
+ return {
119
+ ok: false,
120
+ code: 'Q583',
121
+ notFound: true,
122
+ entries: [],
123
+ why: 'Q583 — room identifier is missing; cannot resolve a local git repository.',
124
+ };
125
+ }
126
+
127
+ const data = loadRepos();
128
+ const rawList = normalizeEntries(data.rooms[roomId]);
129
+ const validEntries = [];
130
+ const staleEntries = [];
131
+
132
+ for (const e of rawList) {
133
+ const normalized = { ...e, root: path.resolve(e.root) };
134
+ if (isEntryValidForRoom(normalized, roomId)) {
135
+ validEntries.push(normalized);
136
+ } else {
137
+ staleEntries.push(normalized);
138
+ }
139
+ }
140
+
141
+ // If the current session joined by branch, also check its own branchCtx directly
142
+ if (opts.preferredRoot) {
143
+ const prefRoot = path.resolve(opts.preferredRoot);
144
+ const alreadyInValid = validEntries.find(e => path.resolve(e.root) === prefRoot);
145
+ if (alreadyInValid) {
146
+ return { ok: true, entry: alreadyInValid };
147
+ }
148
+ if (opts.preferredBranch) {
149
+ const candidate = { root: prefRoot, branch: opts.preferredBranch, link: opts.preferredLink || '' };
150
+ if (isEntryValidForRoom(candidate, roomId)) {
151
+ return { ok: true, entry: candidate };
152
+ }
153
+ }
154
+ }
155
+
156
+ if (validEntries.length === 1) {
157
+ return { ok: true, entry: validEntries[0] };
158
+ }
159
+
160
+ if (validEntries.length > 1) {
161
+ return {
162
+ ok: false,
163
+ code: 'Q584',
164
+ ambiguous: true,
165
+ entries: validEntries,
166
+ why:
167
+ `Q584 — ${validEntries.length} repositories on this machine are bound to room ${roomId}:\n` +
168
+ validEntries.map(e => ` - ${e.root} (branch ${e.branch})`).join('\n') +
169
+ `\nRefusing to pick one silently. Join by branch from the clone you want to land from (hive_join with branch + repo), or unbind the extra clone.`,
170
+ };
171
+ }
172
+
173
+ if (staleEntries.length > 0) {
174
+ return {
175
+ ok: false,
176
+ code: 'Q584',
177
+ stale: true,
178
+ entries: [],
179
+ why:
180
+ `Q584 — the registered repository for room ${roomId} on this machine (${staleEntries.map(e => `${e.root}#${e.branch}`).join(', ')}) ` +
181
+ `is no longer bound to this room or was moved/deleted. Re-bind with: npx -y hivecode-mcp@latest branch <branch> --join "<link>"`,
182
+ };
183
+ }
184
+
185
+ return {
186
+ ok: false,
187
+ code: 'Q583',
188
+ notFound: true,
189
+ entries: [],
190
+ why: `Q583 — room ${roomId} is not bound to a git branch on this machine.`,
191
+ };
192
+ }
193
+
194
+ const ALLOWED_HOLDER_KEYS = ['branch', 'holder', 'host', 'updatedAt'];
195
+
196
+ /**
197
+ * Write a display-only repo_holder record into the room Y.Doc.
198
+ * Strips all filesystem paths (root, cwd, dir, path). Never throws.
199
+ */
200
+ export function writeRepoHolder(doc, info = {}) {
201
+ try {
202
+ if (!doc || typeof doc.getMap !== 'function') return false;
203
+ const map = doc.getMap('repo_holder');
204
+ const branch = typeof info.branch === 'string' ? info.branch.trim() : '';
205
+ const holder = typeof (info.holder || info.name) === 'string' ? String(info.holder || info.name).trim() : '';
206
+ const host = typeof info.host === 'string' && info.host.trim() ? info.host.trim() : os.hostname();
207
+ const updatedAt = new Date().toISOString();
208
+
209
+ const apply = () => {
210
+ for (const k of Array.from(map.keys())) {
211
+ if (!ALLOWED_HOLDER_KEYS.includes(k)) map.delete(k);
212
+ }
213
+ if (branch) map.set('branch', branch);
214
+ if (holder) map.set('holder', holder);
215
+ if (host) map.set('host', host);
216
+ map.set('updatedAt', updatedAt);
217
+ };
218
+ if (typeof doc.transact === 'function') doc.transact(apply);
219
+ else apply();
220
+ return true;
221
+ } catch {
222
+ return false;
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Read the display-only repo_holder record from the room Y.Doc.
228
+ * Returns only { branch, holder, host, updatedAt } (never any path field), or null.
229
+ */
230
+ export function readRepoHolder(doc) {
231
+ try {
232
+ if (!doc || typeof doc.getMap !== 'function') return null;
233
+ const map = doc.getMap('repo_holder');
234
+ const branch = typeof map.get('branch') === 'string' ? map.get('branch') : '';
235
+ const holder = typeof map.get('holder') === 'string' ? map.get('holder') : '';
236
+ const host = typeof map.get('host') === 'string' ? map.get('host') : '';
237
+ const updatedAt = typeof map.get('updatedAt') === 'string' ? map.get('updatedAt') : '';
238
+ if (!branch && !holder && !host) return null;
239
+ const out = { branch, holder, host };
240
+ if (updatedAt) out.updatedAt = updatedAt;
241
+ return out;
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Factory for the hive_pr MCP tool handler.
249
+ * Accepts an injectable `land` function (defaults to `landAndOpen` from hive-land.js).
250
+ */
251
+ export function makeHivePr({
252
+ land = landAndOpen,
253
+ getSession,
254
+ lookup = lookupRepo,
255
+ readHolder = readRepoHolder,
256
+ register = registerRepo,
257
+ writeHolder = writeRepoHolder,
258
+ } = {}) {
259
+ return async function hivePr(args = {}) {
260
+ const s = typeof getSession === 'function' ? getSession() : null;
261
+ if (!s || !s.hive) {
262
+ return 'Q585 — not in a room. Call hive_join first.';
263
+ }
264
+
265
+ const st = typeof s.hive.status === 'function' ? s.hive.status() : {};
266
+ if (st && st.connected === false) {
267
+ return 'Q586 — room connection is currently offline; wait for reconnect before running hive_pr.';
268
+ }
269
+
270
+ const linkStr = s.inviteLink || (s.relay && s.room ? `${s.relay}|${s.room}` : '');
271
+ const roomId = s.room || parseLink(linkStr).room;
272
+ const found = lookup(roomId, {
273
+ preferredRoot: s.branchCtx?.root,
274
+ preferredBranch: s.branchCtx?.branch,
275
+ preferredLink: linkStr,
276
+ });
277
+
278
+ if (!found || !found.ok) {
279
+ const holder = readHolder(s.hive.doc);
280
+ if (found?.code === 'Q584') {
281
+ const extra = holder && (holder.holder || holder.host)
282
+ ? `\nRoom repo holder record: ${holder.holder || 'unknown'} (${holder.host || 'unknown'}) on branch \`${holder.branch || 'unknown'}\`.`
283
+ : '';
284
+ return `${found.why}${extra}`;
285
+ }
286
+ if (holder && (holder.holder || holder.host)) {
287
+ const who = holder.holder || 'another member';
288
+ const where = holder.host || 'another machine';
289
+ const br = holder.branch || 'unknown';
290
+ return (
291
+ `Q582 — this room's repository is not on this machine. ` +
292
+ `The repo for branch \`${br}\` is on ${who} (${where}). ` +
293
+ `Ask @${who} to run hive_pr, or run it on ${where}.`
294
+ );
295
+ }
296
+ return (
297
+ found?.why ||
298
+ `Q583 — room ${roomId || '(unknown)'} is not bound to a git branch on this machine. ` +
299
+ `Bind a repository first with: npx -y hivecode-mcp@latest branch <branch> --join "${linkStr || '<link>'}"`
300
+ );
301
+ }
302
+
303
+ // Strict boolean check: only `confirm: true` lands/pushes/opens a PR.
304
+ // Security: `force`, `test`, and `testMinutes` are never accepted from MCP callers;
305
+ // test command comes from testCommandFor(root, branch) in hive-land.js (saved git config or branch-tip package.json).
306
+ const confirmed = args?.confirm === true;
307
+ const by = s.owner ? `${s.name} for ${s.owner}` : s.name;
308
+ const configuredTest = testCommandFor(found.entry.root, found.entry.branch).command;
309
+
310
+ const res = await land({
311
+ cwd: found.entry.root,
312
+ branch: found.entry.branch,
313
+ dry: !confirmed,
314
+ push: confirmed,
315
+ pr: confirmed,
316
+ base: args?.base,
317
+ test: configuredTest,
318
+ title: args?.title,
319
+ summary: args?.summary,
320
+ by,
321
+ allowWorkflowChanges: args?.allowWorkflowChanges === true,
322
+ only: args?.only,
323
+ });
324
+
325
+ const lines = [];
326
+
327
+ if (!res || !res.ok) {
328
+ if (res?.why) {
329
+ lines.push(res.why);
330
+ } else {
331
+ lines.push(`hive_pr refused${res?.code ? ` (${res.code})` : ''}.`);
332
+ }
333
+
334
+ // Check if caller holds claims itself on Q575
335
+ const holders = Array.isArray(res?.holders) ? res.holders : [];
336
+ const selfHeld = holders.filter(h => h && h.by === s.name).map(h => h.file);
337
+ const otherHeld = holders.filter(h => h && h.by !== s.name);
338
+ if (selfHeld.length > 0) {
339
+ for (const f of selfHeld) {
340
+ lines.push(`Note: you hold ${f}, release it first (call hive_release).`);
341
+ }
342
+ }
343
+ if (otherHeld.length > 0) {
344
+ lines.push(
345
+ `Held by teammates: ` +
346
+ otherHeld.map(h => `${h.file} (by ${h.by}${h.intent ? `: ${h.intent}` : ''})`).join(', ')
347
+ );
348
+ }
349
+
350
+ if (Array.isArray(res?.orphans) && res.orphans.length > 0) {
351
+ lines.push(
352
+ `Orphan files (changed in room with no verified receipt — will NOT land): ` +
353
+ res.orphans.join(', ')
354
+ );
355
+ }
356
+ return lines.join('\n');
357
+ }
358
+
359
+ // Success (either dry-run or confirmed landing)
360
+ if (!confirmed) {
361
+ lines.push(`DRY RUN — hive_pr plan for branch \`${found.entry.branch}\` in ${found.entry.root}:`);
362
+ if (res.why) lines.push(res.why);
363
+ const plan = Array.isArray(res.plan) ? res.plan : [];
364
+ if (plan.length === 0) {
365
+ if (!res.why) {
366
+ lines.push(' (no receipt-verified file changes to land)');
367
+ }
368
+ } else {
369
+ for (const group of plan) {
370
+ const files = Array.isArray(group.files) ? group.files.join(', ') : '';
371
+ const intents = Array.isArray(group.intents) && group.intents.length ? ` — ${group.intents.join('; ')}` : '';
372
+ lines.push(` • ${group.agent || 'agent'}: ${files}${intents}`);
373
+ }
374
+ }
375
+ if (Array.isArray(res.deletions) && res.deletions.length > 0) {
376
+ lines.push(` • Deletions: ${res.deletions.join(', ')}`);
377
+ }
378
+ if (Array.isArray(res.orphans) && res.orphans.length > 0) {
379
+ lines.push(
380
+ `⚠️ Orphans (changed in room with no verified receipt — will NOT land): ` +
381
+ res.orphans.join(', ')
382
+ );
383
+ }
384
+ lines.push(`\nNothing was committed or pushed. Call hive_pr with confirm: true to land, push, and open/update the PR.`);
385
+ return lines.join('\n');
386
+ }
387
+
388
+ // Confirmed landing succeeded: refresh local registry + room holder record
389
+ register({
390
+ room: roomId,
391
+ root: found.entry.root,
392
+ branch: found.entry.branch,
393
+ link: linkStr || found.entry.link,
394
+ });
395
+ writeHolder(s.hive.doc, {
396
+ branch: found.entry.branch,
397
+ holder: s.name,
398
+ host: os.hostname(),
399
+ });
400
+
401
+ lines.push(`LANDED on branch \`${found.entry.branch}\`${res.tip ? ` (tip ${res.tip})` : ''}.`);
402
+ if (res.why) lines.push(res.why);
403
+ const plan = Array.isArray(res.plan) ? res.plan : [];
404
+ for (const group of plan) {
405
+ const files = Array.isArray(group.files) ? group.files.join(', ') : '';
406
+ const intents = Array.isArray(group.intents) && group.intents.length ? ` — ${group.intents.join('; ')}` : '';
407
+ lines.push(` • ${group.agent || 'agent'}: ${files}${intents}`);
408
+ }
409
+ if (res.tests?.ran) {
410
+ lines.push(` • Tests: ${res.tests.command} -> ${res.tests.ok ? 'PASS' : 'FAIL'} (${res.tests.seconds ?? 0}s)`);
411
+ }
412
+ if (Array.isArray(res.orphans) && res.orphans.length > 0) {
413
+ lines.push(
414
+ `⚠️ Orphans (changed in room with no verified receipt — did NOT land): ` +
415
+ res.orphans.join(', ')
416
+ );
417
+ }
418
+ if (res.pr?.url) {
419
+ const action = res.pr.created ? 'opened' : 'updated';
420
+ lines.push(` • PR #${res.pr.number || ''} ${action}: ${res.pr.url}`);
421
+ try {
422
+ s.hive.say(
423
+ `PR ${action} for branch \`${found.entry.branch}\`: ${res.pr.url}` +
424
+ (res.tip ? ` (tip ${res.tip})` : '')
425
+ );
426
+ } catch {}
427
+ }
428
+ return lines.join('\n');
429
+ };
430
+ }