hivecode-mcp 0.14.1 → 0.14.3
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 +98 -98
- package/LICENSE +21 -21
- package/README.md +421 -421
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -310
- package/docs/GITHUB_WORKFLOW.md +394 -394
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -157
- package/docs/QUICKSTART.md +266 -266
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +474 -474
- package/docs/TROUBLESHOOTING.md +106 -105
- package/hive-activity.js +336 -336
- package/hive-authors.js +46 -44
- package/hive-await.js +230 -230
- package/hive-branch.js +674 -607
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +3 -1
- package/hive-hook-install.js +179 -179
- package/hive-instructions.js +74 -74
- package/hive-land.js +709 -691
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +520 -520
- package/hive-mcp-http.js +591 -591
- package/hive-mcp.js +92 -12
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +354 -351
- package/hive-notify.js +234 -233
- package/hive-obligations.js +247 -247
- package/hive-poll-proto.js +50 -50
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +637 -512
- package/hive-privacy.js +327 -327
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +234 -234
- package/hive-repos.js +430 -430
- package/hive-room-key.js +210 -210
- package/hive-room-mode.js +1761 -1668
- package/hive-room-watcher.js +623 -561
- package/hive-secrets.js +89 -89
- package/hive-session.js +114 -114
- package/hive-setup.js +560 -560
- package/hive-slack-agent.js +408 -408
- package/hive-slack.js +2417 -2367
- package/hive-stdio.js +172 -172
- package/hive-stop-hook.js +293 -293
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -71
- package/hive-transport.js +617 -578
- package/hive-update.js +58 -58
- package/hive-version.js +110 -110
- package/hive-webhook.js +55 -54
- package/icr-merge.js +58 -58
- package/icr.js +856 -851
- package/lang-brace.js +305 -305
- package/lang-js.js +695 -651
- package/lang-json.js +211 -211
- package/lang-python.js +588 -588
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -413
- package/package.json +11 -3
- package/reconcile.cjs +621 -621
- package/slack-app-manifest-agent.json +61 -61
- package/slack-app-manifest-inbound.json +36 -36
- package/slack-app-manifest.json +27 -27
- package/substrate.js +252 -252
- package/sync.js +434 -60
- package/token.js +201 -201
package/hive-branch.js
CHANGED
|
@@ -1,607 +1,674 @@
|
|
|
1
|
-
// hive-branch -- a Hivecode room bound to a git branch.
|
|
2
|
-
//
|
|
3
|
-
// npx -y hivecode-mcp@latest branch <name> [--from <ref>] [--name <you>] [--relay <url>]
|
|
4
|
-
// npx -y hivecode-mcp@latest branch <name> --join "<link>" (a teammate, in their own clone)
|
|
5
|
-
// npx -y hivecode-mcp@latest branch <name> --reseed (the relay lost the room)
|
|
6
|
-
// npx -y hivecode-mcp@latest pr <name> [--dry-run] [--push] [--pr] [--base <b>] [--force "<reason>"]
|
|
7
|
-
//
|
|
8
|
-
// Agents join it with hive_join { branch: "<name>" } from the repo (or { branch, repo }).
|
|
9
|
-
//
|
|
10
|
-
// WHY. Developers already know branches. "Everyone on fix-x, people and agents, in one live
|
|
11
|
-
// workspace" needs no new words, and the work arrives as an ordinary PR on fix-x, so a team does
|
|
12
|
-
// not adopt anything to receive it. Git stays the record; the room is where the work happens.
|
|
13
|
-
//
|
|
14
|
-
// THE FOUR RULES, each a way this hurts somebody when it is wrong:
|
|
15
|
-
// 1. THE ROOM ID IS RANDOM. A room named after its branch would put every repo's
|
|
16
|
-
// `feature/login` into one room, and on an open relay anyone could guess it and read the code.
|
|
17
|
-
// 2. A ROOM FOLDER IS NEVER A GIT CHECKOUT. Each holds a `.git` FILE naming a git directory that
|
|
18
|
-
// does not exist, so git refuses to run inside it: no checkout, pull, rebase, stash, reset or
|
|
19
|
-
// clean run there can rewrite the room's files. Git in the developer's own repo never reaches
|
|
20
|
-
// the room either, because nobody works in that checkout. A branch that moved underneath the
|
|
21
|
-
// room is caught when the work lands (hive-pr's staleness check, then a compare-and-swap).
|
|
22
|
-
// 3. ONE FOLDER PER PARTICIPANT, ~/hivecode/<room>/<name>. Two participants in one folder cannot
|
|
23
|
-
// be told apart, and claims and per-agent commits both depend on telling them apart.
|
|
24
|
-
// 4. NOBODY COMMITS INSIDE THE ROOM. Work leaves once, through `pr`, which builds the commits in a
|
|
25
|
-
// temporary worktree so the developer's own checkout is never switched or written.
|
|
26
|
-
//
|
|
27
|
-
// Error codes Q560-Q574 are this module's: Q560-Q569 binding and seeding, Q570-Q574 landing.
|
|
28
|
-
|
|
29
|
-
import fs from 'fs'
|
|
30
|
-
import os from 'os'
|
|
31
|
-
import path from 'path'
|
|
32
|
-
import crypto from 'crypto'
|
|
33
|
-
import { spawnSync } from 'child_process'
|
|
34
|
-
import ignore from 'ignore'
|
|
35
|
-
import { startSync, parseLink, ALWAYS_IGNORE, SKIP } from './sync.js'
|
|
36
|
-
import { PUBLIC_DEFAULT_RELAY, currentRelayFor } from './hive-privacy.js'
|
|
37
|
-
import { findSecrets } from './hive-secrets.js'
|
|
38
|
-
import { validateMemberName, suggestMemberName } from './hive-mailbox.js'
|
|
39
|
-
import { isSafeRelPath } from './token.js'
|
|
40
|
-
|
|
41
|
-
const TEXT_MAX = 1_000_000 // sync.js MAX_BYTES: a larger file never crosses the relay
|
|
42
|
-
|
|
43
|
-
// For hive-land.js, the one landing path. Declared below; exported here so the landing reads the
|
|
44
|
-
// binding, the branch and the room with exactly the rules this module binds and seeds with.
|
|
45
|
-
export { projectRoot, validBranch, revParse, coded, safeSegment, isOwnFile, git as gitIn, waitFor }
|
|
46
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
47
|
-
const coded = (code, msg) => Object.assign(new Error(`${code} ${msg}`), { code })
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
export const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
fs.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
*
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
const
|
|
300
|
-
const
|
|
301
|
-
const
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
process.env.HIVE_PROVENANCE
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
seeder.
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
try {
|
|
346
|
-
try {
|
|
347
|
-
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
await
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
try {
|
|
364
|
-
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
await
|
|
378
|
-
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
try {
|
|
384
|
-
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
if (
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
if (
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
1
|
+
// hive-branch -- a Hivecode room bound to a git branch.
|
|
2
|
+
//
|
|
3
|
+
// npx -y hivecode-mcp@latest branch <name> [--from <ref>] [--name <you>] [--relay <url>]
|
|
4
|
+
// npx -y hivecode-mcp@latest branch <name> --join "<link>" (a teammate, in their own clone)
|
|
5
|
+
// npx -y hivecode-mcp@latest branch <name> --reseed (the relay lost the room)
|
|
6
|
+
// npx -y hivecode-mcp@latest pr <name> [--dry-run] [--push] [--pr] [--base <b>] [--force "<reason>"]
|
|
7
|
+
//
|
|
8
|
+
// Agents join it with hive_join { branch: "<name>" } from the repo (or { branch, repo }).
|
|
9
|
+
//
|
|
10
|
+
// WHY. Developers already know branches. "Everyone on fix-x, people and agents, in one live
|
|
11
|
+
// workspace" needs no new words, and the work arrives as an ordinary PR on fix-x, so a team does
|
|
12
|
+
// not adopt anything to receive it. Git stays the record; the room is where the work happens.
|
|
13
|
+
//
|
|
14
|
+
// THE FOUR RULES, each a way this hurts somebody when it is wrong:
|
|
15
|
+
// 1. THE ROOM ID IS RANDOM. A room named after its branch would put every repo's
|
|
16
|
+
// `feature/login` into one room, and on an open relay anyone could guess it and read the code.
|
|
17
|
+
// 2. A ROOM FOLDER IS NEVER A GIT CHECKOUT. Each holds a `.git` FILE naming a git directory that
|
|
18
|
+
// does not exist, so git refuses to run inside it: no checkout, pull, rebase, stash, reset or
|
|
19
|
+
// clean run there can rewrite the room's files. Git in the developer's own repo never reaches
|
|
20
|
+
// the room either, because nobody works in that checkout. A branch that moved underneath the
|
|
21
|
+
// room is caught when the work lands (hive-pr's staleness check, then a compare-and-swap).
|
|
22
|
+
// 3. ONE FOLDER PER PARTICIPANT, ~/hivecode/<room>/<name>. Two participants in one folder cannot
|
|
23
|
+
// be told apart, and claims and per-agent commits both depend on telling them apart.
|
|
24
|
+
// 4. NOBODY COMMITS INSIDE THE ROOM. Work leaves once, through `pr`, which builds the commits in a
|
|
25
|
+
// temporary worktree so the developer's own checkout is never switched or written.
|
|
26
|
+
//
|
|
27
|
+
// Error codes Q560-Q574 are this module's: Q560-Q569 binding and seeding, Q570-Q574 landing.
|
|
28
|
+
|
|
29
|
+
import fs from 'fs'
|
|
30
|
+
import os from 'os'
|
|
31
|
+
import path from 'path'
|
|
32
|
+
import crypto from 'crypto'
|
|
33
|
+
import { spawnSync } from 'child_process'
|
|
34
|
+
import ignore from 'ignore'
|
|
35
|
+
import { startSync, parseLink, ALWAYS_IGNORE, SKIP } from './sync.js'
|
|
36
|
+
import { PUBLIC_DEFAULT_RELAY, currentRelayFor } from './hive-privacy.js'
|
|
37
|
+
import { findSecrets } from './hive-secrets.js'
|
|
38
|
+
import { validateMemberName, suggestMemberName } from './hive-mailbox.js'
|
|
39
|
+
import { isSafeRelPath } from './token.js'
|
|
40
|
+
|
|
41
|
+
const TEXT_MAX = 1_000_000 // sync.js MAX_BYTES: a larger file never crosses the relay
|
|
42
|
+
|
|
43
|
+
// For hive-land.js, the one landing path. Declared below; exported here so the landing reads the
|
|
44
|
+
// binding, the branch and the room with exactly the rules this module binds and seeds with.
|
|
45
|
+
export { projectRoot, validBranch, revParse, coded, safeSegment, isOwnFile, git as gitIn, waitFor }
|
|
46
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
47
|
+
const coded = (code, msg) => Object.assign(new Error(`${code} ${msg}`), { code })
|
|
48
|
+
const toLF = (s) => (typeof s === 'string' && s.indexOf('\r\n') !== -1 ? s.replace(/\r\n/g, '\n') : s)
|
|
49
|
+
|
|
50
|
+
// ---- where things live --------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
// Read at call time, not import time, so a caller (or a test) that sets HIVE_HOME is honoured.
|
|
53
|
+
export const hiveHome = () => process.env.HIVE_HOME || path.join(os.homedir(), 'hivecode')
|
|
54
|
+
// The same folder rule as hive-mcp.js, so a branch room's folders sit beside every other room's.
|
|
55
|
+
function safeSegment(s) {
|
|
56
|
+
const t = String(s || '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[.]+/, '_').slice(0, 64) || '_'
|
|
57
|
+
return /^(con|prn|aux|nul|com\d|lpt\d)$/i.test(t) ? '_' + t : t
|
|
58
|
+
}
|
|
59
|
+
export function roomFolder(link, name) {
|
|
60
|
+
return path.join(hiveHome(), safeSegment(parseLink(link).room), safeSegment(name))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---- git ----------------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
// OUR SCRATCH WORKTREES RUN WITHOUT THE DEVELOPER'S GIT HOOKS. A post-checkout hook that exits 1
|
|
66
|
+
// made `pr` fail with Q563 although git had made the worktree (measured 25 Oct 2026, a vexp hook);
|
|
67
|
+
// others run npm install or rewrite files in what is a scratch folder about to be deleted. Only
|
|
68
|
+
// commands inside our own temporary worktrees: the developer's checkout and every push keep their
|
|
69
|
+
// hooks. A hooks path that is never created means "no hooks" on every platform git runs on.
|
|
70
|
+
export const NO_HOOKS_DIR = path.join(os.tmpdir(), 'hivecode-no-hooks-never-created')
|
|
71
|
+
export const noHooks = (args) => ['-c', `core.hooksPath=${NO_HOOKS_DIR}`, ...args]
|
|
72
|
+
/** The same for a child process that runs git itself. Appends to any GIT_CONFIG_* already set. */
|
|
73
|
+
export function noHooksEnv(env = process.env) {
|
|
74
|
+
const n = Number(env.GIT_CONFIG_COUNT) > 0 ? Number(env.GIT_CONFIG_COUNT) : 0
|
|
75
|
+
return { ...env, GIT_CONFIG_COUNT: String(n + 1), [`GIT_CONFIG_KEY_${n}`]: 'core.hooksPath', [`GIT_CONFIG_VALUE_${n}`]: NO_HOOKS_DIR }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function git(cwd, args, { input, raw = false, trim = true } = {}) {
|
|
79
|
+
const r = spawnSync('git', args, { cwd, input, encoding: raw ? undefined : 'utf8', maxBuffer: 1024 * 1024 * 1024, windowsHide: true })
|
|
80
|
+
if (r.error) return { code: -1, out: raw ? Buffer.alloc(0) : '', err: r.error.message }
|
|
81
|
+
let out = r.stdout == null ? (raw ? Buffer.alloc(0) : '') : r.stdout
|
|
82
|
+
if (!raw && trim) out = out.replace(/\s+$/, '')
|
|
83
|
+
return { code: r.status, out, err: String(r.stderr || '').trim() }
|
|
84
|
+
}
|
|
85
|
+
const revParse = (root, ref) => { const r = git(root, ['rev-parse', '--verify', '--quiet', ref]); return r.code === 0 ? r.out : '' }
|
|
86
|
+
|
|
87
|
+
/** The top of the git working tree containing `dir`, or null. A room folder answers null: git refuses there. */
|
|
88
|
+
export function repoRoot(dir) {
|
|
89
|
+
if (!dir || !fs.existsSync(dir)) return null
|
|
90
|
+
const r = git(dir, ['rev-parse', '--show-toplevel'])
|
|
91
|
+
return r.code === 0 && r.out ? path.resolve(r.out) : null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Links are resolved on both sides: git names a repository's top with them resolved, while the home
|
|
95
|
+
// directory comes as written, and on macOS the temp folders sit behind one (6 Oct 2026).
|
|
96
|
+
const sameDir = (a, b) => {
|
|
97
|
+
const n = (p) => {
|
|
98
|
+
let r = path.resolve(p)
|
|
99
|
+
try { r = fs.realpathSync.native(r) } catch { /* a path that cannot be resolved is compared as written */ }
|
|
100
|
+
return r.replace(/[\\/]+$/, '')
|
|
101
|
+
}
|
|
102
|
+
return process.platform === 'win32' ? n(a).toLowerCase() === n(b).toLowerCase() : n(a) === n(b)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The repository a branch room is for. NOT THE HOME DIRECTORY: a folder that is not a repository of
|
|
107
|
+
* its own sits inside an enclosing one if there is one, and a home directory kept under git is the
|
|
108
|
+
* usual enclosing one -- so "the repo" silently becomes $HOME and its committed files would be
|
|
109
|
+
* published to a room. Measured on the development machine, whose home directory is a git repo: a
|
|
110
|
+
* test's "outside any repo" folder under the temp directory bound, branched and seeded the home repo.
|
|
111
|
+
*/
|
|
112
|
+
function projectRoot(cwd) {
|
|
113
|
+
const root = repoRoot(cwd)
|
|
114
|
+
if (!root) throw coded('Q560', `${cwd} is not inside a git repository. Run this in the repo whose branch the room is for.`)
|
|
115
|
+
if (sameDir(root, os.homedir())) throw coded('Q560', `the git repository around ${cwd} is your home directory (${root}), which is not a project: ${cwd} is not a repository of its own. Run this in the project's own repository.`)
|
|
116
|
+
return root
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function validBranch(root, name) {
|
|
120
|
+
// A leading '-' would be read as an option by every git command it is later passed to.
|
|
121
|
+
const r = !name || name.startsWith('-') ? { code: 1 } : git(root, ['check-ref-format', '--branch', name])
|
|
122
|
+
if (r.code !== 0) throw coded('Q561', `"${name}" is not a valid branch name.`)
|
|
123
|
+
return r.out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The binding lives in the repo's own git config (shared by all its worktrees, never committed,
|
|
127
|
+
// never pushed), under hivecode.<branch>.*: room = the join link, from = the branch a PR goes
|
|
128
|
+
// into, base = the commit the room was seeded from, seed = the developer's own room folder.
|
|
129
|
+
const KEY = (branch, k) => `hivecode.${branch}.${k}`
|
|
130
|
+
export function readBinding(root, branch) {
|
|
131
|
+
const get = (k) => { const r = git(root, ['config', '--local', '--get', KEY(branch, k)]); return r.code === 0 ? r.out : '' }
|
|
132
|
+
const link = get('room')
|
|
133
|
+
return link ? { link, from: get('from'), base: get('base'), seed: get('seed'), files: get('files') } : null
|
|
134
|
+
}
|
|
135
|
+
function writeBinding(root, branch, b) {
|
|
136
|
+
for (const [k, v] of Object.entries(b)) {
|
|
137
|
+
if (!v) continue
|
|
138
|
+
const r = git(root, ['config', '--local', KEY(branch, k), v])
|
|
139
|
+
if (r.code !== 0) throw coded('Q569', `could not record the room in git config: ${r.err}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Every regular file in `commit`, with its bytes. Symlinks and submodules are not files a room holds. */
|
|
144
|
+
export function treeFiles(root, commit) {
|
|
145
|
+
const ls = git(root, ['ls-tree', '-r', '-z', '--full-tree', commit], { trim: false })
|
|
146
|
+
if (ls.code !== 0) throw coded('Q563', `could not read the files of ${commit}: ${ls.err}`)
|
|
147
|
+
const entries = []
|
|
148
|
+
for (const rec of ls.out.split('\0')) {
|
|
149
|
+
const tab = rec.indexOf('\t')
|
|
150
|
+
if (tab < 0) continue
|
|
151
|
+
const [mode, type, sha] = rec.slice(0, tab).split(' ')
|
|
152
|
+
const rel = rec.slice(tab + 1)
|
|
153
|
+
if (type !== 'blob' || (mode !== '100644' && mode !== '100755') || !isSafeRelPath(rel)) continue
|
|
154
|
+
entries.push({ rel, mode, sha })
|
|
155
|
+
}
|
|
156
|
+
const blobs = readBlobs(root, [...new Set(entries.map((e) => e.sha))])
|
|
157
|
+
return entries.filter((e) => blobs.has(e.sha)).map((e) => ({ ...e, buf: blobs.get(e.sha) }))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `git cat-file --batch`, parsed: sha -> Buffer, exactly the bytes git stored (no smudge, no CRLF). */
|
|
161
|
+
function readBlobs(root, shas) {
|
|
162
|
+
const out = new Map()
|
|
163
|
+
if (!shas.length) return out
|
|
164
|
+
const r = git(root, ['cat-file', '--batch'], { input: shas.join('\n') + '\n', raw: true })
|
|
165
|
+
if (r.code !== 0) throw coded('Q563', `could not read file contents from git: ${r.err}`)
|
|
166
|
+
const buf = r.out
|
|
167
|
+
let i = 0
|
|
168
|
+
while (i < buf.length) {
|
|
169
|
+
const nl = buf.indexOf(10, i)
|
|
170
|
+
if (nl < 0) break
|
|
171
|
+
const [sha, type, size] = buf.subarray(i, nl).toString('utf8').split(' ')
|
|
172
|
+
if (type === 'missing' || size === undefined) { i = nl + 1; continue }
|
|
173
|
+
const n = Number(size)
|
|
174
|
+
out.set(sha, buf.subarray(nl + 1, nl + 1 + n))
|
|
175
|
+
i = nl + 1 + n + 1
|
|
176
|
+
}
|
|
177
|
+
return out
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Would a sync client publish this file? The same tests the sync applies -- text, at most 1 MB,
|
|
182
|
+
* not always-ignored, not ignored by a .gitignore in the tree, not one of Hivecode's own files --
|
|
183
|
+
* so the seed can say exactly what the room will hold, and a joiner can be given the rest from git.
|
|
184
|
+
*/
|
|
185
|
+
export function roomCarries(files) {
|
|
186
|
+
const ig = ignore().add(ALWAYS_IGNORE)
|
|
187
|
+
const rootIgnore = files.find((f) => f.rel === '.gitignore')
|
|
188
|
+
if (rootIgnore) ig.add(rootIgnore.buf.toString('utf8'))
|
|
189
|
+
const nested = files.filter((f) => f.rel.endsWith('/.gitignore'))
|
|
190
|
+
.map((f) => ({ prefix: f.rel.slice(0, -'.gitignore'.length), ig: ignore().add(f.buf.toString('utf8')) }))
|
|
191
|
+
const ignored = (rel) => {
|
|
192
|
+
if (ig.ignores(rel)) return true
|
|
193
|
+
for (const n of nested) {
|
|
194
|
+
if (!rel.startsWith(n.prefix)) continue
|
|
195
|
+
const sub = rel.slice(n.prefix.length)
|
|
196
|
+
if (sub && n.ig.ignores(sub)) return true
|
|
197
|
+
}
|
|
198
|
+
return false
|
|
199
|
+
}
|
|
200
|
+
// A file holding a key is held at home by every client (sync.js heldForSecret), so a room that
|
|
201
|
+
// promised to carry it would never finish seeding (measured 3 Oct 2026: Q565, "4 of 5 files",
|
|
202
|
+
// nothing bound). It is not carried, and each participant gets it from git like a binary.
|
|
203
|
+
const holdsKey = (f) => process.env.HIVE_SHARE_SECRETS !== '1' && findSecrets(f.buf.toString('utf8')).length > 0
|
|
204
|
+
return (f) => f.buf.length <= TEXT_MAX && !f.buf.includes(0) && !SKIP.has(f.rel) && !ignored(f.rel) && !holdsKey(f)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Hivecode's own bookkeeping (.hive.json, .hive-shadow.json, .hive-lock.json, ...). If a repo happens
|
|
208
|
+
// to track one, it is never written into a room folder: a foreign .hive-lock.json would make the
|
|
209
|
+
// folder look held by some other process, and a foreign shadow is somebody else's ancestry.
|
|
210
|
+
// By exact name: a project's own `.hiverc` is its business. All of these are already never carried
|
|
211
|
+
// (always-ignored, or rendered locally), so the seed and a fresh joiner agree on what the room holds.
|
|
212
|
+
const OWN_FILES = new Set(['.hive.json', '.hive-shadow.json', '.hive-shadow.json.tmp', '.hive-lock.json', '.hive-id.json', '.hive-mcp-session.json', '.hive-stop-hook.json'])
|
|
213
|
+
const isOwnFile = (rel) => OWN_FILES.has(path.posix.basename(rel))
|
|
214
|
+
|
|
215
|
+
function writeFiles(dir, files) {
|
|
216
|
+
for (const f of files) {
|
|
217
|
+
if (isOwnFile(f.rel)) continue
|
|
218
|
+
const full = path.join(dir, ...f.rel.split('/'))
|
|
219
|
+
fs.mkdirSync(path.dirname(full), { recursive: true })
|
|
220
|
+
fs.writeFileSync(full, f.buf)
|
|
221
|
+
if (f.mode === '100755' && process.platform !== 'win32') { try { fs.chmodSync(full, 0o755) } catch { /* best effort */ } }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- rule 2: a room folder is never a git checkout ------------------------------------------
|
|
226
|
+
|
|
227
|
+
// One line, because git reads a `.git` file's first line as the path to the repository. The path
|
|
228
|
+
// does not exist, so every git command run in the folder stops with "not a git repository" and the
|
|
229
|
+
// path it prints says why. An EMPTY `.git` directory would not do this: git skips an invalid one
|
|
230
|
+
// and walks on up to the nearest real repository (measured: `git stash -u` run inside the folder
|
|
231
|
+
// then stashed the room's files away through the parent repo, which deletes them).
|
|
232
|
+
export const NOT_A_CHECKOUT = 'gitdir: ./.hivecode-live-room-not-a-git-checkout\n'
|
|
233
|
+
|
|
234
|
+
export function markNotACheckout(dir) {
|
|
235
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
236
|
+
const p = path.join(dir, '.git')
|
|
237
|
+
let st = null
|
|
238
|
+
try { st = fs.lstatSync(p) } catch { /* absent: ours to create */ }
|
|
239
|
+
if (st) {
|
|
240
|
+
if (st.isFile() && toLF(fs.readFileSync(p, 'utf8')) === NOT_A_CHECKOUT) return { ok: true }
|
|
241
|
+
return { ok: false, why: `${dir} is a git checkout (it has its own .git). A branch room's folders never are: git run there could rewrite the room's files. Leave \`dir\` out and the folder is chosen for you.` }
|
|
242
|
+
}
|
|
243
|
+
fs.writeFileSync(p, NOT_A_CHECKOUT)
|
|
244
|
+
return { ok: true }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The files the room cannot carry (binary, over 1 MB, always-ignored), written into a joiner's
|
|
249
|
+
* folder from the branch. ONLY those: a file the room does carry but that is missing here was
|
|
250
|
+
* deleted in the room, and writing it back would publish the deletion's undo to everyone.
|
|
251
|
+
*/
|
|
252
|
+
export function fillFromGit({ root, commit, dir }) {
|
|
253
|
+
const files = treeFiles(root, commit)
|
|
254
|
+
const carries = roomCarries(files)
|
|
255
|
+
const missing = files.filter((f) => !carries(f) && !SKIP.has(f.rel) && !isOwnFile(f.rel) && !fs.existsSync(path.join(dir, ...f.rel.split('/'))))
|
|
256
|
+
writeFiles(dir, missing)
|
|
257
|
+
return missing.length
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* What hive_join needs for { branch }: the room link, and the commit to fill non-text files from.
|
|
262
|
+
* Returns { ok:false, message } with the next step instead of throwing -- an agent acts on text.
|
|
263
|
+
*/
|
|
264
|
+
export function resolveForJoin({ branch, repo }) {
|
|
265
|
+
let root
|
|
266
|
+
try { root = projectRoot(repo) } catch (e) {
|
|
267
|
+
return { ok: false, message: `${e.message} It has no branch room "${branch}". Pass repo: "<path to the repo>" -- or join with the room's link instead.` }
|
|
268
|
+
}
|
|
269
|
+
let name
|
|
270
|
+
try { name = validBranch(root, branch) } catch (e) { return { ok: false, message: e.message } }
|
|
271
|
+
const b = readBinding(root, name)
|
|
272
|
+
if (!b) return { ok: false, message: `Q562 branch "${name}" has no Hivecode room in directory ${root}. If your repository is elsewhere, pass repo: "/path/to/repo" in hive_join. Otherwise create one with:\n npx -y hivecode-mcp@latest branch ${name}\nor, if a teammate already made it, records it with:\n npx -y hivecode-mcp@latest branch ${name} --join "<link>"` }
|
|
273
|
+
// A teammate's clone usually has the branch only as origin/<name>; that is as good a source for
|
|
274
|
+
// the files the room does not carry. With neither, those files are simply not filled.
|
|
275
|
+
const commit = revParse(root, `refs/heads/${name}`) || revParse(root, `refs/remotes/origin/${name}`) || b.base
|
|
276
|
+
return { ok: true, root, branch: name, link: b.link, commit }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---- seeding: the room starts as the branch ------------------------------------------------
|
|
280
|
+
|
|
281
|
+
async function waitFor(pred, ms, every = 200) {
|
|
282
|
+
const t0 = Date.now()
|
|
283
|
+
for (;;) {
|
|
284
|
+
let v = false
|
|
285
|
+
try { v = pred() } catch { /* not ready */ }
|
|
286
|
+
if (v) return true
|
|
287
|
+
if (Date.now() - t0 > ms) return false
|
|
288
|
+
await sleep(every)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Publish `commit` into the (empty) room from the developer's own room folder, and report success
|
|
294
|
+
* only after a FRESH JOINER has received every file the room should hold, byte for byte. A seed
|
|
295
|
+
* that is merely "sent" is the false success this refuses: agents would build on a partial tree.
|
|
296
|
+
*/
|
|
297
|
+
async function seedRoom({ root, branch, commit, link, you, log }) {
|
|
298
|
+
const { relay, room } = parseLink(link)
|
|
299
|
+
const files = treeFiles(root, commit)
|
|
300
|
+
const carries = roomCarries(files)
|
|
301
|
+
const expected = new Map(files.filter(carries).map((f) => [f.rel, toLF(f.buf.toString('utf8'))]))
|
|
302
|
+
const dir = roomFolder(link, you)
|
|
303
|
+
const mark = markNotACheckout(dir)
|
|
304
|
+
if (!mark.ok) throw coded('Q564', mark.why)
|
|
305
|
+
writeFiles(dir, files)
|
|
306
|
+
fs.writeFileSync(path.join(dir, '.hive.json'), JSON.stringify({ relay, room }, null, 2))
|
|
307
|
+
|
|
308
|
+
// PROVENANCE OFF FOR THE SEED. The seed is the branch exactly as it already is; a receipt for it
|
|
309
|
+
// would name the developer as a co-author of every file an agent later changes. Agents' first
|
|
310
|
+
// receipts still chain from it, because a receipt's `parent` is the hash of what it built on.
|
|
311
|
+
const prov = process.env.HIVE_PROVENANCE
|
|
312
|
+
process.env.HIVE_PROVENANCE = 'off'
|
|
313
|
+
let seeder
|
|
314
|
+
try { seeder = startSync({ relay, room, dir, name: you, kind: 'human', log: () => {} }) }
|
|
315
|
+
finally { if (prov === undefined) delete process.env.HIVE_PROVENANCE; else process.env.HIVE_PROVENANCE = prov }
|
|
316
|
+
|
|
317
|
+
const budget = 30000 + 40 * expected.size
|
|
318
|
+
let verifier = null
|
|
319
|
+
const vdir = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-seed-check-'))
|
|
320
|
+
try {
|
|
321
|
+
const up = await waitFor(() => seeder.connection().connected && [...expected].every(([r, t]) => seeder._fileText(r) === t), budget)
|
|
322
|
+
if (!up) {
|
|
323
|
+
const st = seeder.status()
|
|
324
|
+
throw coded('Q565', `the seed did not finish publishing: the room holds ${st.roomFiles} of ${expected.size} files after ${Math.round(budget / 1000)} s. ${st.notSyncing ? `${st.notSyncing} file(s) were refused by the relay. ` : ''}Nothing was bound; run the same command again.`)
|
|
325
|
+
}
|
|
326
|
+
verifier = startSync({ relay, room, dir: vdir, name: `${you}-check`, kind: 'ai', passive: true, log: () => {} })
|
|
327
|
+
const read = (r) => { try { return toLF(fs.readFileSync(path.join(vdir, ...r.split('/')), 'utf8')) } catch { return null } }
|
|
328
|
+
const got = await waitFor(() => [...expected].every(([r, t]) => read(r) === t), budget, 300)
|
|
329
|
+
if (!got) {
|
|
330
|
+
const missing = [...expected.keys()].filter((r) => read(r) !== expected.get(r))
|
|
331
|
+
throw coded('Q566', `a fresh joiner did not receive the whole branch: ${missing.length} of ${expected.size} files are missing or different (first: ${missing.slice(0, 5).join(', ')}). Nothing was bound; agents would have built on a partial tree.`)
|
|
332
|
+
}
|
|
333
|
+
const short = commit.slice(0, 7)
|
|
334
|
+
// Who holds this room's repo, for display only: an agent on another machine asked to open a PR
|
|
335
|
+
// is told whom to ask. Never a path; what lands is decided on the landing machine alone.
|
|
336
|
+
try { const R = await import('./hive-repos.js'); R.writeRepoHolder(seeder.doc, { branch, holder: you, host: os.hostname() }) } catch { /* display only */ }
|
|
337
|
+
// THE COMMIT THE ROOM STARTS FROM, for a teammate's `--join`: without it their clone cannot tell
|
|
338
|
+
// a file the room deleted from one added to the branch later, and landing from there skipped the
|
|
339
|
+
// room's deletions -- and the Q572 guard -- without a word (antigravity, 5 Nov 2026).
|
|
340
|
+
try { seeder.doc.getMap('seed').set('commit', commit); seeder.doc.getMap('seed').set('branch', branch) } catch { /* older relay docs */ }
|
|
341
|
+
seeder.say(`Room for git branch \`${branch}\` @ ${short}, seeded by ${you} with ${expected.size} file(s). Work here lands on \`${branch}\` as one commit per agent via \`npx -y hivecode-mcp@latest pr ${branch}\`. Room folders are not git checkouts on purpose: edit files, do not commit.`)
|
|
342
|
+
await sleep(600) // let the announcement leave before the socket closes
|
|
343
|
+
return { dir, count: expected.size, total: files.length }
|
|
344
|
+
} finally {
|
|
345
|
+
try { verifier && verifier.stop() } catch { /* going away */ }
|
|
346
|
+
try { seeder.stop() } catch { /* going away */ }
|
|
347
|
+
try { fs.rmSync(vdir, { recursive: true, force: true }) } catch { /* temp */ }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** The paths the room lists right now, from a passive look that syncs no files (null if unreachable). */
|
|
352
|
+
async function roomPaths(link, you) {
|
|
353
|
+
const { relay, room } = parseLink(link)
|
|
354
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-peek-'))
|
|
355
|
+
const peek = startSync({ relay, room, dir, name: `${you}-peek`, kind: 'ai', passive: true, syncFiles: false, log: () => {} })
|
|
356
|
+
try {
|
|
357
|
+
let synced = false
|
|
358
|
+
peek.provider.on('sync', (s) => { if (s) synced = true })
|
|
359
|
+
if (!(await waitFor(() => synced, 15000))) return null
|
|
360
|
+
await sleep(300)
|
|
361
|
+
return new Set(peek.doc.getMap('manifest').keys())
|
|
362
|
+
} finally {
|
|
363
|
+
try { peek.stop() } catch { }
|
|
364
|
+
try { fs.rmSync(dir, { recursive: true, force: true }) } catch { }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const roomFileCount = async (link, you) => { const p = await roomPaths(link, you); return p ? p.size : null }
|
|
368
|
+
|
|
369
|
+
/** The commit the room was seeded from, as its seeder recorded it ({ commit, branch }), or null. */
|
|
370
|
+
async function roomSeed(link, you) {
|
|
371
|
+
const { relay, room } = parseLink(link)
|
|
372
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-peek-'))
|
|
373
|
+
const peek = startSync({ relay, room, dir, name: `${you}-peek`, kind: 'ai', passive: true, syncFiles: false, log: () => {} })
|
|
374
|
+
try {
|
|
375
|
+
let synced = false
|
|
376
|
+
peek.provider.on('sync', (s) => { if (s) synced = true })
|
|
377
|
+
if (!(await waitFor(() => synced, 15000))) return null
|
|
378
|
+
await sleep(300)
|
|
379
|
+
const m = peek.doc.getMap('seed')
|
|
380
|
+
const commit = m.get('commit')
|
|
381
|
+
return typeof commit === 'string' && /^[0-9a-f]{40}$/i.test(commit) ? { commit, branch: String(m.get('branch') || '') } : null
|
|
382
|
+
} finally {
|
|
383
|
+
try { peek.stop() } catch { }
|
|
384
|
+
try { fs.rmSync(dir, { recursive: true, force: true }) } catch { }
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// A NEW BRANCH IS CUT FROM WHAT THE TEAM HAS, NOT FROM A STALE COPY OF IT (P1-7, 9 Nov 2026).
|
|
389
|
+
// `branch fix-x` cuts from HEAD, and HEAD is usually a local main that was last pulled days ago: the
|
|
390
|
+
// room was then seeded with old files, agents worked on them, and landing met the newer main as a
|
|
391
|
+
// conflict (or a Q506) nobody had caused. So when the source is a LOCAL branch, its remote copy is
|
|
392
|
+
// fetched first, and:
|
|
393
|
+
// - local strictly BEHIND remote: cut from the remote. Nothing is lost (the remote holds every
|
|
394
|
+
// local commit) and the note says so, with the count.
|
|
395
|
+
// - local AHEAD of remote: cut from local. Those commits are the developer's own work.
|
|
396
|
+
// - DIVERGED: cut from local, and the note says how far behind and how to choose the remote.
|
|
397
|
+
// - the fetch failed (offline, no access): cut from local, and the note says it could not check.
|
|
398
|
+
// A sha or a remote ref (`origin/main`) is taken exactly as given: whoever names one chose it, and
|
|
399
|
+
// the GitHub watcher fetches before it passes one. HIVE_BRANCH_FETCH=0 skips the fetch and compares
|
|
400
|
+
// against the remote ref as last fetched.
|
|
401
|
+
function fetchBranch(root, remote, rb) {
|
|
402
|
+
if (process.env.HIVE_BRANCH_FETCH === '0') return { ok: false, skipped: true, err: 'HIVE_BRANCH_FETCH=0' }
|
|
403
|
+
const r = spawnSync('git', ['fetch', '--quiet', '--no-tags', remote, `+refs/heads/${rb}:refs/remotes/${remote}/${rb}`], {
|
|
404
|
+
cwd: root, encoding: 'utf8', timeout: 30000, windowsHide: true, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
405
|
+
})
|
|
406
|
+
if (r.error) return { ok: false, err: r.error.code === 'ETIMEDOUT' ? 'timed out after 30 s' : r.error.message }
|
|
407
|
+
if (r.status === 0) return { ok: true }
|
|
408
|
+
const err = String(r.stderr || '').trim()
|
|
409
|
+
// The remote has no such branch (a main never pushed): nothing to be behind, nothing to say.
|
|
410
|
+
if (/couldn't find remote ref|could not find remote ref/i.test(err)) return { ok: false, absent: true, err }
|
|
411
|
+
return { ok: false, err: err.split('\n').pop() || `exit ${r.status}` }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Where a new branch is cut from: { sha, src, note }. See the comment above. */
|
|
415
|
+
export function freshCutPoint(root, from = '') {
|
|
416
|
+
const src = from || 'HEAD'
|
|
417
|
+
const sha = revParse(root, `${src}^{commit}`)
|
|
418
|
+
if (!sha) return { sha: '', src, note: '' }
|
|
419
|
+
let local = ''
|
|
420
|
+
if (src === 'HEAD') { const c = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']); local = c.code === 0 ? c.out : '' }
|
|
421
|
+
else if (!/^[0-9a-f]{7,40}$/i.test(src) && revParse(root, `refs/heads/${src}`)) local = src
|
|
422
|
+
if (!local) return { sha, src, note: '' } // detached HEAD, a sha, a tag or a remote ref: exactly as given
|
|
423
|
+
// Its remote copy: the configured upstream, else the same name on origin.
|
|
424
|
+
let remote = '', rb = ''
|
|
425
|
+
const up = git(root, ['for-each-ref', '--format=%(upstream:remotename)%09%(upstream:short)', `refs/heads/${local}`])
|
|
426
|
+
const [upRemote, upShort] = (up.code === 0 ? up.out : '').split('\t')
|
|
427
|
+
if (upRemote && upShort && upShort.startsWith(`${upRemote}/`)) { remote = upRemote; rb = upShort.slice(upRemote.length + 1) }
|
|
428
|
+
else if (git(root, ['remote']).out.split('\n').includes('origin')) { remote = 'origin'; rb = local }
|
|
429
|
+
if (!remote) return { sha, src, note: '' } // a repository with no remote: local is all there is
|
|
430
|
+
const rref = `${remote}/${rb}`
|
|
431
|
+
const f = fetchBranch(root, remote, rb)
|
|
432
|
+
const rsha = revParse(root, `refs/remotes/${rref}^{commit}`)
|
|
433
|
+
if (!rsha) {
|
|
434
|
+
return { sha, src, note: f.ok || f.skipped || f.absent ? '' : `could not fetch ${rref} (${f.err}), so ${local} was not checked against it.` }
|
|
435
|
+
}
|
|
436
|
+
const unchecked = f.ok || f.skipped ? '' : ` (could not fetch it just now: ${f.err}; this is ${rref} as last fetched)`
|
|
437
|
+
if (rsha === sha) return { sha, src, note: unchecked ? `${local} matches ${rref}${unchecked}.` : '' }
|
|
438
|
+
const isAnc = (a, b) => git(root, ['merge-base', '--is-ancestor', a, b]).code === 0
|
|
439
|
+
const count = (range) => Number(git(root, ['rev-list', '--count', range]).out) || 0
|
|
440
|
+
if (isAnc(sha, rsha)) {
|
|
441
|
+
const n = count(`${sha}..${rsha}`)
|
|
442
|
+
return { sha: rsha, src: rref, behind: n, note: `your ${local} was ${n} commit(s) behind ${rref}${unchecked}, so the branch was cut from ${rref} (${rsha.slice(0, 7)}) instead. Nothing of yours is left out: ${rref} has every commit your ${local} has.` }
|
|
443
|
+
}
|
|
444
|
+
if (isAnc(rsha, sha)) return { sha, src, ahead: count(`${rsha}..${sha}`), note: unchecked ? `cut from your ${local}${unchecked}.` : '' }
|
|
445
|
+
const behind = count(`${sha}..${rsha}`), ahead = count(`${rsha}..${sha}`)
|
|
446
|
+
return { sha, src, behind, ahead, note: `your ${local} and ${rref} have diverged (${behind} behind, ${ahead} ahead)${unchecked}. The branch was cut from your ${local}; to cut from ${rref} instead, delete the branch and the room binding and run again with --from ${rref}.` }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The branch a PR from `name` goes into: the checkout's current branch, else origin's default. */
|
|
450
|
+
function defaultBase(root, name) {
|
|
451
|
+
const cur = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
|
|
452
|
+
if (cur.code === 0 && cur.out && cur.out !== name) return cur.out
|
|
453
|
+
const o = git(root, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
|
|
454
|
+
const d = o.code === 0 && o.out ? o.out.replace(/^origin\//, '') : ''
|
|
455
|
+
return d !== name ? d : ''
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Delete a room folder ONLY if it is one this tool made: under HIVE_HOME, carrying our `.git`
|
|
460
|
+
* line, and naming this room in its .hive.json. The path comes from git config, and config is
|
|
461
|
+
* editable text -- a path that fails any of these is refused, never removed.
|
|
462
|
+
*/
|
|
463
|
+
export function removeRoomFolder(dir, room) {
|
|
464
|
+
const abs = path.resolve(dir)
|
|
465
|
+
const home = path.resolve(hiveHome()) + path.sep
|
|
466
|
+
let ours = false
|
|
467
|
+
try {
|
|
468
|
+
ours = abs.startsWith(home) &&
|
|
469
|
+
toLF(fs.readFileSync(path.join(abs, '.git'), 'utf8')) === NOT_A_CHECKOUT &&
|
|
470
|
+
JSON.parse(fs.readFileSync(path.join(abs, '.hive.json'), 'utf8')).room === room
|
|
471
|
+
} catch { ours = false }
|
|
472
|
+
if (!fs.existsSync(abs)) return
|
|
473
|
+
if (!ours) throw coded('Q568', `refusing to delete ${abs}: it is not a room folder this tool made for ${room}. Move it aside yourself, then run --reseed again.`)
|
|
474
|
+
fs.rmSync(abs, { recursive: true, force: true })
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const projectRootOrNull = (cwd) => { try { return projectRoot(cwd) } catch { return null } }
|
|
478
|
+
|
|
479
|
+
// THIS MACHINE'S RECORD OF WHICH REPO HOLDS WHICH ROOM (hive-repos.js), so an agent that joined by
|
|
480
|
+
// link -- not by branch -- can still land from here with hive_pr. Imported late: hive-repos imports
|
|
481
|
+
// this module. Best effort: a registry that cannot be written never fails a bind, and hive_pr then
|
|
482
|
+
// says exactly that the room is not bound on this machine (Q583), with the command that fixes it.
|
|
483
|
+
async function registerLocal(root, branch, link) {
|
|
484
|
+
try { const R = await import('./hive-repos.js'); return !!R.registerRepo({ root, branch, link }) } catch { return false }
|
|
485
|
+
}
|
|
486
|
+
function youFrom(root, given) {
|
|
487
|
+
const raw = given || git(root, ['config', 'user.name']).out || os.userInfo().username || 'dev'
|
|
488
|
+
return validateMemberName(raw).ok ? raw : suggestMemberName(raw)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* `branch <name>`: bind a room to a branch, creating and seeding both on first use.
|
|
493
|
+
* The developer's checkout is never switched or written: the branch is made with `git branch`,
|
|
494
|
+
* and the seed is read out of git objects, not out of the working tree.
|
|
495
|
+
*/
|
|
496
|
+
export async function bindBranch({ cwd, branch, from = '', relay = '', you = '', join = '', reseed = false, log = () => {} }) {
|
|
497
|
+
const root = projectRoot(cwd)
|
|
498
|
+
const name = validBranch(root, branch)
|
|
499
|
+
const me = youFrom(root, you)
|
|
500
|
+
const existing = readBinding(root, name)
|
|
501
|
+
|
|
502
|
+
if (join) {
|
|
503
|
+
const { relay: jr, room } = parseLink(join)
|
|
504
|
+
if (!jr || !room) throw coded('Q567', `"${join}" is not a room link (it looks like wss://relay|room-...).`)
|
|
505
|
+
if (existing && existing.link !== join) throw coded('Q567', `branch "${name}" is already bound to a different room here (${existing.link}). Remove it with: git config --remove-section "hivecode.${name}"`)
|
|
506
|
+
const seed = await roomSeed(join, me)
|
|
507
|
+
writeBinding(root, name, { room: join, base: seed && seed.commit ? seed.commit : '' })
|
|
508
|
+
await registerLocal(root, name, join)
|
|
509
|
+
return { root, branch: name, link: join, you: me, joined: true, base: seed && seed.commit ? seed.commit : '' }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (existing && !reseed) {
|
|
513
|
+
const n = await roomFileCount(existing.link, me)
|
|
514
|
+
// Empty is only news when the seed was not: a branch with nothing a room can carry seeds nothing.
|
|
515
|
+
const lost = n === 0 && Number(existing.files || 1) > 0
|
|
516
|
+
await registerLocal(root, name, existing.link) // a binding made before the registry existed gets its entry
|
|
517
|
+
return { root, branch: name, link: existing.link, you: me, from: existing.from, existing: true, roomFiles: n, lost }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const relayUrl = currentRelayFor(relay || process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY).relay
|
|
521
|
+
let created = false
|
|
522
|
+
let tip = revParse(root, `refs/heads/${name}`)
|
|
523
|
+
// The PR's base is a BRANCH NAME for `gh`: a sha names no branch, and origin/main means main.
|
|
524
|
+
let fromName = from && !/^[0-9a-f]{7,40}$/i.test(from) ? from.replace(/^origin\//, '') : ''
|
|
525
|
+
let cutNote = ''
|
|
526
|
+
if (!tip) {
|
|
527
|
+
const src = from || 'HEAD'
|
|
528
|
+
const cut = freshCutPoint(root, from)
|
|
529
|
+
const sha = cut.sha
|
|
530
|
+
cutNote = cut.note || ''
|
|
531
|
+
if (cutNote) log(cutNote)
|
|
532
|
+
if (!sha) {
|
|
533
|
+
if (src === 'HEAD' && !revParse(root, 'HEAD')) {
|
|
534
|
+
throw coded('Q562', `repository "${root}" has no commits yet, so there's nothing to put in a room. Commit your files first, then run this again.`)
|
|
535
|
+
}
|
|
536
|
+
throw coded('Q562', `"${src}" is not a commit in ${root}.`)
|
|
537
|
+
}
|
|
538
|
+
const r = git(root, ['branch', '--no-track', name, sha]) // `branch`, never `checkout -b`: the checkout stays where it is
|
|
539
|
+
if (r.code !== 0) throw coded('Q562', `could not create branch ${name}: ${r.err}`)
|
|
540
|
+
tip = sha
|
|
541
|
+
created = true
|
|
542
|
+
}
|
|
543
|
+
if (!fromName) fromName = defaultBase(root, name)
|
|
544
|
+
|
|
545
|
+
let link = existing && existing.link
|
|
546
|
+
if (reseed) {
|
|
547
|
+
if (!existing) throw coded('Q562', `branch "${name}" has no room to reseed; run without --reseed to create one.`)
|
|
548
|
+
const n = await roomFileCount(existing.link, me)
|
|
549
|
+
if (n === null) throw coded('Q565', `could not reach the relay behind ${existing.link}.`)
|
|
550
|
+
if (n > 0) throw coded('Q568', `the room still holds ${n} file(s). --reseed only refills an EMPTY room (after the relay lost it); reseeding a live room would put the branch's old content over the agents' work.`)
|
|
551
|
+
// A fresh seed folder: the old one's record of what it last wrote belongs to the lost room.
|
|
552
|
+
removeRoomFolder(existing.seed || roomFolder(existing.link, me), parseLink(existing.link).room)
|
|
553
|
+
if (existing.seed && path.resolve(existing.seed) !== path.resolve(roomFolder(existing.link, me))) removeRoomFolder(roomFolder(existing.link, me), parseLink(existing.link).room)
|
|
554
|
+
} else {
|
|
555
|
+
link = `${relayUrl}|room-${crypto.randomBytes(13).toString('base64url')}`
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
let seeded
|
|
559
|
+
try {
|
|
560
|
+
seeded = await seedRoom({ root, branch: name, commit: tip, link, you: me, log })
|
|
561
|
+
} catch (e) {
|
|
562
|
+
// A refused bind leaves nothing behind: not a binding, not a branch nobody asked to keep, and
|
|
563
|
+
// not a half-seeded folder for the next attempt (which gets a new room) to trip over.
|
|
564
|
+
if (created && revParse(root, `refs/heads/${name}`) === tip) git(root, ['branch', '-D', name])
|
|
565
|
+
try { removeRoomFolder(roomFolder(link, me), parseLink(link).room) } catch { /* not ours to remove */ }
|
|
566
|
+
throw e
|
|
567
|
+
}
|
|
568
|
+
writeBinding(root, name, { room: link, from: fromName, base: tip, seed: seeded.dir, files: String(seeded.count) })
|
|
569
|
+
await registerLocal(root, name, link)
|
|
570
|
+
return { root, branch: name, link, you: me, from: fromName, commit: tip, created, cutNote, reseeded: reseed, files: seeded.count, total: seeded.total, seedDir: seeded.dir }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ---- landing: the room's work becomes commits on the branch ---------------------------------
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* `pr <name>` used to land here. Landing now has ONE path, hive-land.js landAndOpen, shared by the
|
|
577
|
+
* `pr` command, the hive_pr tool and the GitHub watcher: every check (claims, an empty room, workflow
|
|
578
|
+
* files, keys, tests, origin ahead, the lock) protects all three. Two copies of the landing had
|
|
579
|
+
* already drifted once (the deletion accounting lived only here).
|
|
580
|
+
*
|
|
581
|
+
* landBranch keeps its old contract for the watcher, which still runs its own workflow, key and test
|
|
582
|
+
* checks after landing, and whose agent has exited by the time it lands (so its claims are moot):
|
|
583
|
+
* { ok:true, landed, tip, deletions } | { ok:false, code } with the reason on logErr.
|
|
584
|
+
*/
|
|
585
|
+
export async function landBranch({ cwd, branch, dry = false, push = false, pr = false, base = '', force = '', only = '', title = '', log = console.log, logErr = console.error }) {
|
|
586
|
+
const { landAndOpen } = await import('./hive-land.js')
|
|
587
|
+
const r = await landAndOpen({ cwd, branch, dry: !!dry, push, pr, base, force, only, title, log, logErr, claims: false, checks: false, by: 'the Hivecode GitHub watcher' })
|
|
588
|
+
if (!r.ok) { logErr(r.why); return { ok: false, code: 1, refused: r.code } }
|
|
589
|
+
return r.dry ? { ok: true, dry: true, deletions: r.deletions } : { ok: true, landed: r.landed, tip: r.tip, deletions: r.deletions }
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ---- CLI ----------------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
function flags(argv) {
|
|
595
|
+
const pos = []
|
|
596
|
+
const f = {}
|
|
597
|
+
for (let i = 0; i < argv.length; i++) {
|
|
598
|
+
const a = argv[i]
|
|
599
|
+
if (!a.startsWith('--')) { pos.push(a); continue }
|
|
600
|
+
const k = a.slice(2)
|
|
601
|
+
if (['dry-run', 'push', 'pr', 'reseed', 'allow-workflow-changes', 'allow-mass-delete', 'save'].includes(k)) f[k] = true
|
|
602
|
+
else f[k] = argv[++i] || ''
|
|
603
|
+
}
|
|
604
|
+
return { pos, f }
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const USAGE = `usage:
|
|
608
|
+
npx -y hivecode-mcp@latest branch <name> [--from <ref>] [--name <you>] [--relay <url>]
|
|
609
|
+
npx -y hivecode-mcp@latest branch <name> --join "<link>"
|
|
610
|
+
npx -y hivecode-mcp@latest branch <name> --reseed
|
|
611
|
+
npx -y hivecode-mcp@latest pr <name> [--dry-run] [--push] [--pr] [--base <branch>] [--test "<command>" [--test-copy <file,...>] [--test-setup "<cmd>"] [--save]] [--test-minutes <n>]
|
|
612
|
+
[--allow-workflow-changes] [--allow-mass-delete] [--title "<t>"] [--force "<reason>"]`
|
|
613
|
+
|
|
614
|
+
/** `argv` starts at the subcommand: ['branch', 'fix-x', ...] or ['pr', 'fix-x', ...]. Returns an exit code. */
|
|
615
|
+
export async function main(argv, { cwd = process.cwd(), log = console.log, logErr = console.error } = {}) {
|
|
616
|
+
const [cmd, ...rest] = argv
|
|
617
|
+
const { pos, f } = flags(rest)
|
|
618
|
+
const name = pos[0]
|
|
619
|
+
if (!name || (cmd !== 'branch' && cmd !== 'pr')) { logErr(USAGE); return 2 }
|
|
620
|
+
try {
|
|
621
|
+
if (cmd === 'pr') {
|
|
622
|
+
const { landAndOpen, testCommandFor, saveTestCommand } = await import('./hive-land.js')
|
|
623
|
+
let test = f.test || ''
|
|
624
|
+
// --test-copy a,b and --test-setup "<cmd>": what the tests need beyond git (see hive-land.js).
|
|
625
|
+
const testCopy = 'test-copy' in f ? String(f['test-copy'] || '').split(',').map((s) => s.trim()).filter(Boolean) : undefined
|
|
626
|
+
const testSetup = 'test-setup' in f ? String(f['test-setup'] || '') : undefined
|
|
627
|
+
if (f.save) {
|
|
628
|
+
if (!test) { logErr(`--save keeps a test command: pass it with --test "<command>"`); return 2 }
|
|
629
|
+
saveTestCommand(cwd, name, test, { copy: testCopy ?? null, setup: testSetup ?? null })
|
|
630
|
+
log(`saved: landing ${name} now runs ${testSetup ? `\`${testSetup}\`, then ` : ''}\`${test}\` first${testCopy && testCopy.length ? `, with ${testCopy.join(', ')} copied in` : ''}, from any caller (pr, hive_pr, room mode). Change it the same way.`)
|
|
631
|
+
}
|
|
632
|
+
// No --test: the command saved for this branch, if any. (The project's own `npm test` is the
|
|
633
|
+
// hive_pr tool's fallback; here the developer is present, so nothing runs they did not choose.)
|
|
634
|
+
if (!test) { const t = testCommandFor(cwd, name); if (t.from === 'saved') { test = t.command; log(`tests: \`${test}\` (saved for ${name})`) } }
|
|
635
|
+
const r = await landAndOpen({
|
|
636
|
+
cwd, branch: name, dry: !!f['dry-run'], push: !!(f.push || f.pr), pr: !!f.pr, base: f.base || '', force: f.force || '', only: f.only || '', title: f.title || '',
|
|
637
|
+
test, testMinutes: Number(f['test-minutes']) || 0, testCopy, testSetup, allowWorkflowChanges: !!f['allow-workflow-changes'], allowMassDelete: !!f['allow-mass-delete'],
|
|
638
|
+
by: youFrom(projectRootOrNull(cwd) || cwd, ''), log, logErr,
|
|
639
|
+
})
|
|
640
|
+
if (!r.ok) { logErr(r.why); return 1 }
|
|
641
|
+
return 0
|
|
642
|
+
}
|
|
643
|
+
const r = await bindBranch({ cwd, branch: name, from: f.from || '', relay: f.relay || '', you: f.name || '', join: f.join || '', reseed: !!f.reseed })
|
|
644
|
+
const agentLine = `Use hive_join with branch "${r.branch}", repo "${r.root.replace(/\\/g, '/')}", as a name of your own (each agent picks a different one)`
|
|
645
|
+
if (r.joined) {
|
|
646
|
+
log(`Recorded: branch ${r.branch} in ${r.root} is the room ${r.link}\n\nTell each of your agents:\n ${agentLine}`)
|
|
647
|
+
return 0
|
|
648
|
+
}
|
|
649
|
+
if (r.existing) {
|
|
650
|
+
log(`Branch ${r.branch} already has a room: ${r.link}${r.roomFiles == null ? '\n(the relay did not answer just now)' : ` (${r.roomFiles} file(s) in it)`}`)
|
|
651
|
+
if (r.lost) log(`\nTHE ROOM IS EMPTY: the relay lost it (a restart or a deploy wipes rooms on some hosts).\nAgents that still have their folders get their work back by joining again:\n ${agentLine}\nIf no agent has a folder any more, refill it from the branch:\n npx -y hivecode-mcp@latest branch ${r.branch} --reseed`)
|
|
652
|
+
} else {
|
|
653
|
+
const dirty = git(r.root, ['status', '--porcelain'])
|
|
654
|
+
const dirtyLines = dirty.out ? dirty.out.split('\n').filter(Boolean) : []
|
|
655
|
+
const dirtyFiles = dirtyLines.map((l) => {
|
|
656
|
+
const p = l.slice(3).trim()
|
|
657
|
+
return p.includes(' -> ') ? p.split(' -> ')[1].trim() : p
|
|
658
|
+
}).filter(Boolean)
|
|
659
|
+
const shown = dirtyFiles.slice(0, 5).join(', ')
|
|
660
|
+
const more = dirtyFiles.length > 5 ? ` (+${dirtyFiles.length - 5} more)` : ''
|
|
661
|
+
const uncommittedPart = dirtyFiles.length ? `\n ${dirtyFiles.length} file(s) with uncommitted changes are not in the room: ${shown}${more}` : ''
|
|
662
|
+
if (r.cutNote) log(`Note: ${r.cutNote}`)
|
|
663
|
+
log(`${r.reseeded ? 'Room refilled' : 'Room ready'} for branch ${r.branch} @ ${r.commit.slice(0, 7)}${r.created ? ` (new branch, cut from ${r.from || 'HEAD'}; your checkout was not switched)` : ''}\n ${r.files} file(s) in the room: the branch as committed${uncommittedPart}${r.total > r.files ? `\n ${r.total - r.files} more (binary, over 1 MB, or never synced) are filled in from git for agents on this machine` : ''}\n room: ${r.link}`)
|
|
664
|
+
if (/onrender\.com/.test(r.link)) log(' This is the public relay: anyone who has this link can read these files.')
|
|
665
|
+
const roomName = parseLink(r.link).room
|
|
666
|
+
log(` Your agents work in their own folders under ~/hivecode/${roomName}/, kept in sync live.\n Your checkout here doesn't change until you run pr.`)
|
|
667
|
+
}
|
|
668
|
+
log(`\nTell each of your agents:\n ${agentLine}\n\nA teammate, in their own clone of this repo:\n npx -y hivecode-mcp@latest branch ${r.branch} --join "${r.link}"\n\nWhen the work is done, land it on ${r.branch} (one commit per agent):\n npx -y hivecode-mcp@latest pr ${r.branch} add --pr to push it and open the pull request`)
|
|
669
|
+
return 0
|
|
670
|
+
} catch (e) {
|
|
671
|
+
logErr(e && e.message ? e.message : String(e))
|
|
672
|
+
return 1
|
|
673
|
+
}
|
|
674
|
+
}
|