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-branch.js CHANGED
@@ -1,607 +1,608 @@
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
- // ---- where things live --------------------------------------------------------------------
50
-
51
- // Read at call time, not import time, so a caller (or a test) that sets HIVE_HOME is honoured.
52
- export const hiveHome = () => process.env.HIVE_HOME || path.join(os.homedir(), 'hivecode')
53
- // The same folder rule as hive-mcp.js, so a branch room's folders sit beside every other room's.
54
- function safeSegment(s) {
55
- const t = String(s || '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[.]+/, '_').slice(0, 64) || '_'
56
- return /^(con|prn|aux|nul|com\d|lpt\d)$/i.test(t) ? '_' + t : t
57
- }
58
- export function roomFolder(link, name) {
59
- return path.join(hiveHome(), safeSegment(parseLink(link).room), safeSegment(name))
60
- }
61
-
62
- // ---- git ----------------------------------------------------------------------------------
63
-
64
- // OUR SCRATCH WORKTREES RUN WITHOUT THE DEVELOPER'S GIT HOOKS. A post-checkout hook that exits 1
65
- // made `pr` fail with Q563 although git had made the worktree (measured 25 Oct 2026, a vexp hook);
66
- // others run npm install or rewrite files in what is a scratch folder about to be deleted. Only
67
- // commands inside our own temporary worktrees: the developer's checkout and every push keep their
68
- // hooks. A hooks path that is never created means "no hooks" on every platform git runs on.
69
- export const NO_HOOKS_DIR = path.join(os.tmpdir(), 'hivecode-no-hooks-never-created')
70
- export const noHooks = (args) => ['-c', `core.hooksPath=${NO_HOOKS_DIR}`, ...args]
71
- /** The same for a child process that runs git itself. Appends to any GIT_CONFIG_* already set. */
72
- export function noHooksEnv(env = process.env) {
73
- const n = Number(env.GIT_CONFIG_COUNT) > 0 ? Number(env.GIT_CONFIG_COUNT) : 0
74
- return { ...env, GIT_CONFIG_COUNT: String(n + 1), [`GIT_CONFIG_KEY_${n}`]: 'core.hooksPath', [`GIT_CONFIG_VALUE_${n}`]: NO_HOOKS_DIR }
75
- }
76
-
77
- function git(cwd, args, { input, raw = false, trim = true } = {}) {
78
- const r = spawnSync('git', args, { cwd, input, encoding: raw ? undefined : 'utf8', maxBuffer: 1024 * 1024 * 1024, windowsHide: true })
79
- if (r.error) return { code: -1, out: raw ? Buffer.alloc(0) : '', err: r.error.message }
80
- let out = r.stdout == null ? (raw ? Buffer.alloc(0) : '') : r.stdout
81
- if (!raw && trim) out = out.replace(/\s+$/, '')
82
- return { code: r.status, out, err: String(r.stderr || '').trim() }
83
- }
84
- const revParse = (root, ref) => { const r = git(root, ['rev-parse', '--verify', '--quiet', ref]); return r.code === 0 ? r.out : '' }
85
-
86
- /** The top of the git working tree containing `dir`, or null. A room folder answers null: git refuses there. */
87
- export function repoRoot(dir) {
88
- if (!dir || !fs.existsSync(dir)) return null
89
- const r = git(dir, ['rev-parse', '--show-toplevel'])
90
- return r.code === 0 && r.out ? path.resolve(r.out) : null
91
- }
92
-
93
- // Links are resolved on both sides: git names a repository's top with them resolved, while the home
94
- // directory comes as written, and on macOS the temp folders sit behind one (6 Oct 2026).
95
- const sameDir = (a, b) => {
96
- const n = (p) => {
97
- let r = path.resolve(p)
98
- try { r = fs.realpathSync.native(r) } catch { /* a path that cannot be resolved is compared as written */ }
99
- return r.replace(/[\\/]+$/, '')
100
- }
101
- return process.platform === 'win32' ? n(a).toLowerCase() === n(b).toLowerCase() : n(a) === n(b)
102
- }
103
-
104
- /**
105
- * The repository a branch room is for. NOT THE HOME DIRECTORY: a folder that is not a repository of
106
- * its own sits inside an enclosing one if there is one, and a home directory kept under git is the
107
- * usual enclosing one -- so "the repo" silently becomes $HOME and its committed files would be
108
- * published to a room. Measured on the development machine, whose home directory is a git repo: a
109
- * test's "outside any repo" folder under the temp directory bound, branched and seeded the home repo.
110
- */
111
- function projectRoot(cwd) {
112
- const root = repoRoot(cwd)
113
- if (!root) throw coded('Q560', `${cwd} is not inside a git repository. Run this in the repo whose branch the room is for.`)
114
- 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.`)
115
- return root
116
- }
117
-
118
- function validBranch(root, name) {
119
- // A leading '-' would be read as an option by every git command it is later passed to.
120
- const r = !name || name.startsWith('-') ? { code: 1 } : git(root, ['check-ref-format', '--branch', name])
121
- if (r.code !== 0) throw coded('Q561', `"${name}" is not a valid branch name.`)
122
- return r.out
123
- }
124
-
125
- // The binding lives in the repo's own git config (shared by all its worktrees, never committed,
126
- // never pushed), under hivecode.<branch>.*: room = the join link, from = the branch a PR goes
127
- // into, base = the commit the room was seeded from, seed = the developer's own room folder.
128
- const KEY = (branch, k) => `hivecode.${branch}.${k}`
129
- export function readBinding(root, branch) {
130
- const get = (k) => { const r = git(root, ['config', '--local', '--get', KEY(branch, k)]); return r.code === 0 ? r.out : '' }
131
- const link = get('room')
132
- return link ? { link, from: get('from'), base: get('base'), seed: get('seed'), files: get('files') } : null
133
- }
134
- function writeBinding(root, branch, b) {
135
- for (const [k, v] of Object.entries(b)) {
136
- if (!v) continue
137
- const r = git(root, ['config', '--local', KEY(branch, k), v])
138
- if (r.code !== 0) throw coded('Q569', `could not record the room in git config: ${r.err}`)
139
- }
140
- }
141
-
142
- /** Every regular file in `commit`, with its bytes. Symlinks and submodules are not files a room holds. */
143
- export function treeFiles(root, commit) {
144
- const ls = git(root, ['ls-tree', '-r', '-z', '--full-tree', commit], { trim: false })
145
- if (ls.code !== 0) throw coded('Q563', `could not read the files of ${commit}: ${ls.err}`)
146
- const entries = []
147
- for (const rec of ls.out.split('\0')) {
148
- const tab = rec.indexOf('\t')
149
- if (tab < 0) continue
150
- const [mode, type, sha] = rec.slice(0, tab).split(' ')
151
- const rel = rec.slice(tab + 1)
152
- if (type !== 'blob' || (mode !== '100644' && mode !== '100755') || !isSafeRelPath(rel)) continue
153
- entries.push({ rel, mode, sha })
154
- }
155
- const blobs = readBlobs(root, [...new Set(entries.map((e) => e.sha))])
156
- return entries.filter((e) => blobs.has(e.sha)).map((e) => ({ ...e, buf: blobs.get(e.sha) }))
157
- }
158
-
159
- /** `git cat-file --batch`, parsed: sha -> Buffer, exactly the bytes git stored (no smudge, no CRLF). */
160
- function readBlobs(root, shas) {
161
- const out = new Map()
162
- if (!shas.length) return out
163
- const r = git(root, ['cat-file', '--batch'], { input: shas.join('\n') + '\n', raw: true })
164
- if (r.code !== 0) throw coded('Q563', `could not read file contents from git: ${r.err}`)
165
- const buf = r.out
166
- let i = 0
167
- while (i < buf.length) {
168
- const nl = buf.indexOf(10, i)
169
- if (nl < 0) break
170
- const [sha, type, size] = buf.subarray(i, nl).toString('utf8').split(' ')
171
- if (type === 'missing' || size === undefined) { i = nl + 1; continue }
172
- const n = Number(size)
173
- out.set(sha, buf.subarray(nl + 1, nl + 1 + n))
174
- i = nl + 1 + n + 1
175
- }
176
- return out
177
- }
178
-
179
- /**
180
- * Would a sync client publish this file? The same tests the sync applies -- text, at most 1 MB,
181
- * not always-ignored, not ignored by a .gitignore in the tree, not one of Hivecode's own files --
182
- * so the seed can say exactly what the room will hold, and a joiner can be given the rest from git.
183
- */
184
- export function roomCarries(files) {
185
- const ig = ignore().add(ALWAYS_IGNORE)
186
- const rootIgnore = files.find((f) => f.rel === '.gitignore')
187
- if (rootIgnore) ig.add(rootIgnore.buf.toString('utf8'))
188
- const nested = files.filter((f) => f.rel.endsWith('/.gitignore'))
189
- .map((f) => ({ prefix: f.rel.slice(0, -'.gitignore'.length), ig: ignore().add(f.buf.toString('utf8')) }))
190
- const ignored = (rel) => {
191
- if (ig.ignores(rel)) return true
192
- for (const n of nested) {
193
- if (!rel.startsWith(n.prefix)) continue
194
- const sub = rel.slice(n.prefix.length)
195
- if (sub && n.ig.ignores(sub)) return true
196
- }
197
- return false
198
- }
199
- // A file holding a key is held at home by every client (sync.js heldForSecret), so a room that
200
- // promised to carry it would never finish seeding (measured 3 Oct 2026: Q565, "4 of 5 files",
201
- // nothing bound). It is not carried, and each participant gets it from git like a binary.
202
- const holdsKey = (f) => process.env.HIVE_SHARE_SECRETS !== '1' && findSecrets(f.buf.toString('utf8')).length > 0
203
- return (f) => f.buf.length <= TEXT_MAX && !f.buf.includes(0) && !SKIP.has(f.rel) && !ignored(f.rel) && !holdsKey(f)
204
- }
205
-
206
- // Hivecode's own bookkeeping (.hive.json, .hive-shadow.json, .hive-lock.json, ...). If a repo happens
207
- // to track one, it is never written into a room folder: a foreign .hive-lock.json would make the
208
- // folder look held by some other process, and a foreign shadow is somebody else's ancestry.
209
- // By exact name: a project's own `.hiverc` is its business. All of these are already never carried
210
- // (always-ignored, or rendered locally), so the seed and a fresh joiner agree on what the room holds.
211
- 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'])
212
- const isOwnFile = (rel) => OWN_FILES.has(path.posix.basename(rel))
213
-
214
- function writeFiles(dir, files) {
215
- for (const f of files) {
216
- if (isOwnFile(f.rel)) continue
217
- const full = path.join(dir, ...f.rel.split('/'))
218
- fs.mkdirSync(path.dirname(full), { recursive: true })
219
- fs.writeFileSync(full, f.buf)
220
- if (f.mode === '100755' && process.platform !== 'win32') { try { fs.chmodSync(full, 0o755) } catch { /* best effort */ } }
221
- }
222
- }
223
-
224
- // ---- rule 2: a room folder is never a git checkout ------------------------------------------
225
-
226
- // One line, because git reads a `.git` file's first line as the path to the repository. The path
227
- // does not exist, so every git command run in the folder stops with "not a git repository" and the
228
- // path it prints says why. An EMPTY `.git` directory would not do this: git skips an invalid one
229
- // and walks on up to the nearest real repository (measured: `git stash -u` run inside the folder
230
- // then stashed the room's files away through the parent repo, which deletes them).
231
- export const NOT_A_CHECKOUT = 'gitdir: ./.hivecode-live-room-not-a-git-checkout\n'
232
-
233
- export function markNotACheckout(dir) {
234
- fs.mkdirSync(dir, { recursive: true })
235
- const p = path.join(dir, '.git')
236
- let st = null
237
- try { st = fs.lstatSync(p) } catch { /* absent: ours to create */ }
238
- if (st) {
239
- if (st.isFile() && fs.readFileSync(p, 'utf8') === NOT_A_CHECKOUT) return { ok: true }
240
- 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.` }
241
- }
242
- fs.writeFileSync(p, NOT_A_CHECKOUT)
243
- return { ok: true }
244
- }
245
-
246
- /**
247
- * The files the room cannot carry (binary, over 1 MB, always-ignored), written into a joiner's
248
- * folder from the branch. ONLY those: a file the room does carry but that is missing here was
249
- * deleted in the room, and writing it back would publish the deletion's undo to everyone.
250
- */
251
- export function fillFromGit({ root, commit, dir }) {
252
- const files = treeFiles(root, commit)
253
- const carries = roomCarries(files)
254
- const missing = files.filter((f) => !carries(f) && !SKIP.has(f.rel) && !isOwnFile(f.rel) && !fs.existsSync(path.join(dir, ...f.rel.split('/'))))
255
- writeFiles(dir, missing)
256
- return missing.length
257
- }
258
-
259
- /**
260
- * What hive_join needs for { branch }: the room link, and the commit to fill non-text files from.
261
- * Returns { ok:false, message } with the next step instead of throwing -- an agent acts on text.
262
- */
263
- export function resolveForJoin({ branch, repo }) {
264
- let root
265
- try { root = projectRoot(repo) } catch (e) {
266
- 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.` }
267
- }
268
- let name
269
- try { name = validBranch(root, branch) } catch (e) { return { ok: false, message: e.message } }
270
- const b = readBinding(root, name)
271
- 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>"` }
272
- // A teammate's clone usually has the branch only as origin/<name>; that is as good a source for
273
- // the files the room does not carry. With neither, those files are simply not filled.
274
- const commit = revParse(root, `refs/heads/${name}`) || revParse(root, `refs/remotes/origin/${name}`) || b.base
275
- return { ok: true, root, branch: name, link: b.link, commit }
276
- }
277
-
278
- // ---- seeding: the room starts as the branch ------------------------------------------------
279
-
280
- async function waitFor(pred, ms, every = 200) {
281
- const t0 = Date.now()
282
- for (;;) {
283
- let v = false
284
- try { v = pred() } catch { /* not ready */ }
285
- if (v) return true
286
- if (Date.now() - t0 > ms) return false
287
- await sleep(every)
288
- }
289
- }
290
-
291
- /**
292
- * Publish `commit` into the (empty) room from the developer's own room folder, and report success
293
- * only after a FRESH JOINER has received every file the room should hold, byte for byte. A seed
294
- * that is merely "sent" is the false success this refuses: agents would build on a partial tree.
295
- */
296
- async function seedRoom({ root, branch, commit, link, you, log }) {
297
- const { relay, room } = parseLink(link)
298
- const files = treeFiles(root, commit)
299
- const carries = roomCarries(files)
300
- const expected = new Map(files.filter(carries).map((f) => [f.rel, f.buf.toString('utf8')]))
301
- const dir = roomFolder(link, you)
302
- const mark = markNotACheckout(dir)
303
- if (!mark.ok) throw coded('Q564', mark.why)
304
- writeFiles(dir, files)
305
- fs.writeFileSync(path.join(dir, '.hive.json'), JSON.stringify({ relay, room }, null, 2))
306
-
307
- // PROVENANCE OFF FOR THE SEED. The seed is the branch exactly as it already is; a receipt for it
308
- // would name the developer as a co-author of every file an agent later changes. Agents' first
309
- // receipts still chain from it, because a receipt's `parent` is the hash of what it built on.
310
- const prov = process.env.HIVE_PROVENANCE
311
- process.env.HIVE_PROVENANCE = 'off'
312
- let seeder
313
- try { seeder = startSync({ relay, room, dir, name: you, kind: 'human', log: () => {} }) }
314
- finally { if (prov === undefined) delete process.env.HIVE_PROVENANCE; else process.env.HIVE_PROVENANCE = prov }
315
-
316
- const budget = 30000 + 40 * expected.size
317
- let verifier = null
318
- const vdir = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-seed-check-'))
319
- try {
320
- const up = await waitFor(() => seeder.connection().connected && [...expected].every(([r, t]) => seeder._fileText(r) === t), budget)
321
- if (!up) {
322
- const st = seeder.status()
323
- 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.`)
324
- }
325
- verifier = startSync({ relay, room, dir: vdir, name: `${you}-check`, kind: 'ai', passive: true, log: () => {} })
326
- const read = (r) => { try { return fs.readFileSync(path.join(vdir, ...r.split('/')), 'utf8') } catch { return null } }
327
- const got = await waitFor(() => [...expected].every(([r, t]) => read(r) === t), budget, 300)
328
- if (!got) {
329
- const missing = [...expected.keys()].filter((r) => read(r) !== expected.get(r))
330
- 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.`)
331
- }
332
- const short = commit.slice(0, 7)
333
- // Who holds this room's repo, for display only: an agent on another machine asked to open a PR
334
- // is told whom to ask. Never a path; what lands is decided on the landing machine alone.
335
- try { const R = await import('./hive-repos.js'); R.writeRepoHolder(seeder.doc, { branch, holder: you, host: os.hostname() }) } catch { /* display only */ }
336
- // THE COMMIT THE ROOM STARTS FROM, for a teammate's `--join`: without it their clone cannot tell
337
- // a file the room deleted from one added to the branch later, and landing from there skipped the
338
- // room's deletions -- and the Q572 guard -- without a word (antigravity, 5 Nov 2026).
339
- try { seeder.doc.getMap('seed').set('commit', commit); seeder.doc.getMap('seed').set('branch', branch) } catch { /* older relay docs */ }
340
- 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.`)
341
- await sleep(600) // let the announcement leave before the socket closes
342
- return { dir, count: expected.size, total: files.length }
343
- } finally {
344
- try { verifier && verifier.stop() } catch { /* going away */ }
345
- try { seeder.stop() } catch { /* going away */ }
346
- try { fs.rmSync(vdir, { recursive: true, force: true }) } catch { /* temp */ }
347
- }
348
- }
349
-
350
- /** The paths the room lists right now, from a passive look that syncs no files (null if unreachable). */
351
- async function roomPaths(link, you) {
352
- const { relay, room } = parseLink(link)
353
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-peek-'))
354
- const peek = startSync({ relay, room, dir, name: `${you}-peek`, kind: 'ai', passive: true, syncFiles: false, log: () => {} })
355
- try {
356
- let synced = false
357
- peek.provider.on('sync', (s) => { if (s) synced = true })
358
- if (!(await waitFor(() => synced, 15000))) return null
359
- await sleep(300)
360
- return new Set(peek.doc.getMap('manifest').keys())
361
- } finally {
362
- try { peek.stop() } catch { }
363
- try { fs.rmSync(dir, { recursive: true, force: true }) } catch { }
364
- }
365
- }
366
- const roomFileCount = async (link, you) => { const p = await roomPaths(link, you); return p ? p.size : null }
367
-
368
- /** The commit the room was seeded from, as its seeder recorded it ({ commit, branch }), or null. */
369
- async function roomSeed(link, you) {
370
- const { relay, room } = parseLink(link)
371
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-peek-'))
372
- const peek = startSync({ relay, room, dir, name: `${you}-peek`, kind: 'ai', passive: true, syncFiles: false, log: () => {} })
373
- try {
374
- let synced = false
375
- peek.provider.on('sync', (s) => { if (s) synced = true })
376
- if (!(await waitFor(() => synced, 15000))) return null
377
- await sleep(300)
378
- const m = peek.doc.getMap('seed')
379
- const commit = m.get('commit')
380
- return typeof commit === 'string' && /^[0-9a-f]{40}$/i.test(commit) ? { commit, branch: String(m.get('branch') || '') } : null
381
- } finally {
382
- try { peek.stop() } catch { }
383
- try { fs.rmSync(dir, { recursive: true, force: true }) } catch { }
384
- }
385
- }
386
-
387
- /** The branch a PR from `name` goes into: the checkout's current branch, else origin's default. */
388
- function defaultBase(root, name) {
389
- const cur = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
390
- if (cur.code === 0 && cur.out && cur.out !== name) return cur.out
391
- const o = git(root, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
392
- const d = o.code === 0 && o.out ? o.out.replace(/^origin\//, '') : ''
393
- return d !== name ? d : ''
394
- }
395
-
396
- /**
397
- * Delete a room folder ONLY if it is one this tool made: under HIVE_HOME, carrying our `.git`
398
- * line, and naming this room in its .hive.json. The path comes from git config, and config is
399
- * editable text -- a path that fails any of these is refused, never removed.
400
- */
401
- export function removeRoomFolder(dir, room) {
402
- const abs = path.resolve(dir)
403
- const home = path.resolve(hiveHome()) + path.sep
404
- let ours = false
405
- try {
406
- ours = abs.startsWith(home) &&
407
- fs.readFileSync(path.join(abs, '.git'), 'utf8') === NOT_A_CHECKOUT &&
408
- JSON.parse(fs.readFileSync(path.join(abs, '.hive.json'), 'utf8')).room === room
409
- } catch { ours = false }
410
- if (!fs.existsSync(abs)) return
411
- 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.`)
412
- fs.rmSync(abs, { recursive: true, force: true })
413
- }
414
-
415
- const projectRootOrNull = (cwd) => { try { return projectRoot(cwd) } catch { return null } }
416
-
417
- // THIS MACHINE'S RECORD OF WHICH REPO HOLDS WHICH ROOM (hive-repos.js), so an agent that joined by
418
- // link -- not by branch -- can still land from here with hive_pr. Imported late: hive-repos imports
419
- // this module. Best effort: a registry that cannot be written never fails a bind, and hive_pr then
420
- // says exactly that the room is not bound on this machine (Q583), with the command that fixes it.
421
- async function registerLocal(root, branch, link) {
422
- try { const R = await import('./hive-repos.js'); return !!R.registerRepo({ root, branch, link }) } catch { return false }
423
- }
424
- function youFrom(root, given) {
425
- const raw = given || git(root, ['config', 'user.name']).out || os.userInfo().username || 'dev'
426
- return validateMemberName(raw).ok ? raw : suggestMemberName(raw)
427
- }
428
-
429
- /**
430
- * `branch <name>`: bind a room to a branch, creating and seeding both on first use.
431
- * The developer's checkout is never switched or written: the branch is made with `git branch`,
432
- * and the seed is read out of git objects, not out of the working tree.
433
- */
434
- export async function bindBranch({ cwd, branch, from = '', relay = '', you = '', join = '', reseed = false, log = () => {} }) {
435
- const root = projectRoot(cwd)
436
- const name = validBranch(root, branch)
437
- const me = youFrom(root, you)
438
- const existing = readBinding(root, name)
439
-
440
- if (join) {
441
- const { relay: jr, room } = parseLink(join)
442
- if (!jr || !room) throw coded('Q567', `"${join}" is not a room link (it looks like wss://relay|room-...).`)
443
- 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}"`)
444
- const seed = await roomSeed(join, me)
445
- writeBinding(root, name, { room: join, base: seed && seed.commit ? seed.commit : '' })
446
- await registerLocal(root, name, join)
447
- return { root, branch: name, link: join, you: me, joined: true, base: seed && seed.commit ? seed.commit : '' }
448
- }
449
-
450
- if (existing && !reseed) {
451
- const n = await roomFileCount(existing.link, me)
452
- // Empty is only news when the seed was not: a branch with nothing a room can carry seeds nothing.
453
- const lost = n === 0 && Number(existing.files || 1) > 0
454
- await registerLocal(root, name, existing.link) // a binding made before the registry existed gets its entry
455
- return { root, branch: name, link: existing.link, you: me, from: existing.from, existing: true, roomFiles: n, lost }
456
- }
457
-
458
- const relayUrl = currentRelayFor(relay || process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY).relay
459
- let created = false
460
- let tip = revParse(root, `refs/heads/${name}`)
461
- // The PR's base is a BRANCH NAME for `gh`: a sha names no branch, and origin/main means main.
462
- let fromName = from && !/^[0-9a-f]{7,40}$/i.test(from) ? from.replace(/^origin\//, '') : ''
463
- if (!tip) {
464
- const src = from || 'HEAD'
465
- const sha = revParse(root, `${src}^{commit}`)
466
- if (!sha) {
467
- if (src === 'HEAD' && !revParse(root, 'HEAD')) {
468
- 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.`)
469
- }
470
- throw coded('Q562', `"${src}" is not a commit in ${root}.`)
471
- }
472
- const r = git(root, ['branch', '--no-track', name, sha]) // `branch`, never `checkout -b`: the checkout stays where it is
473
- if (r.code !== 0) throw coded('Q562', `could not create branch ${name}: ${r.err}`)
474
- tip = sha
475
- created = true
476
- }
477
- if (!fromName) fromName = defaultBase(root, name)
478
-
479
- let link = existing && existing.link
480
- if (reseed) {
481
- if (!existing) throw coded('Q562', `branch "${name}" has no room to reseed; run without --reseed to create one.`)
482
- const n = await roomFileCount(existing.link, me)
483
- if (n === null) throw coded('Q565', `could not reach the relay behind ${existing.link}.`)
484
- 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.`)
485
- // A fresh seed folder: the old one's record of what it last wrote belongs to the lost room.
486
- removeRoomFolder(existing.seed || roomFolder(existing.link, me), parseLink(existing.link).room)
487
- if (existing.seed && path.resolve(existing.seed) !== path.resolve(roomFolder(existing.link, me))) removeRoomFolder(roomFolder(existing.link, me), parseLink(existing.link).room)
488
- } else {
489
- link = `${relayUrl}|room-${crypto.randomBytes(13).toString('base64url')}`
490
- }
491
-
492
- let seeded
493
- try {
494
- seeded = await seedRoom({ root, branch: name, commit: tip, link, you: me, log })
495
- } catch (e) {
496
- // A refused bind leaves nothing behind: not a binding, not a branch nobody asked to keep, and
497
- // not a half-seeded folder for the next attempt (which gets a new room) to trip over.
498
- if (created && revParse(root, `refs/heads/${name}`) === tip) git(root, ['branch', '-D', name])
499
- try { removeRoomFolder(roomFolder(link, me), parseLink(link).room) } catch { /* not ours to remove */ }
500
- throw e
501
- }
502
- writeBinding(root, name, { room: link, from: fromName, base: tip, seed: seeded.dir, files: String(seeded.count) })
503
- await registerLocal(root, name, link)
504
- return { root, branch: name, link, you: me, from: fromName, commit: tip, created, reseeded: reseed, files: seeded.count, total: seeded.total, seedDir: seeded.dir }
505
- }
506
-
507
- // ---- landing: the room's work becomes commits on the branch ---------------------------------
508
-
509
- /**
510
- * `pr <name>` used to land here. Landing now has ONE path, hive-land.js landAndOpen, shared by the
511
- * `pr` command, the hive_pr tool and the GitHub watcher: every check (claims, an empty room, workflow
512
- * files, keys, tests, origin ahead, the lock) protects all three. Two copies of the landing had
513
- * already drifted once (the deletion accounting lived only here).
514
- *
515
- * landBranch keeps its old contract for the watcher, which still runs its own workflow, key and test
516
- * checks after landing, and whose agent has exited by the time it lands (so its claims are moot):
517
- * { ok:true, landed, tip, deletions } | { ok:false, code } with the reason on logErr.
518
- */
519
- export async function landBranch({ cwd, branch, dry = false, push = false, pr = false, base = '', force = '', only = '', title = '', log = console.log, logErr = console.error }) {
520
- const { landAndOpen } = await import('./hive-land.js')
521
- 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' })
522
- if (!r.ok) { logErr(r.why); return { ok: false, code: 1, refused: r.code } }
523
- return r.dry ? { ok: true, dry: true, deletions: r.deletions } : { ok: true, landed: r.landed, tip: r.tip, deletions: r.deletions }
524
- }
525
-
526
- // ---- CLI ----------------------------------------------------------------------------------
527
-
528
- function flags(argv) {
529
- const pos = []
530
- const f = {}
531
- for (let i = 0; i < argv.length; i++) {
532
- const a = argv[i]
533
- if (!a.startsWith('--')) { pos.push(a); continue }
534
- const k = a.slice(2)
535
- if (['dry-run', 'push', 'pr', 'reseed', 'allow-workflow-changes', 'allow-mass-delete', 'save'].includes(k)) f[k] = true
536
- else f[k] = argv[++i] || ''
537
- }
538
- return { pos, f }
539
- }
540
-
541
- const USAGE = `usage:
542
- npx -y hivecode-mcp@latest branch <name> [--from <ref>] [--name <you>] [--relay <url>]
543
- npx -y hivecode-mcp@latest branch <name> --join "<link>"
544
- npx -y hivecode-mcp@latest branch <name> --reseed
545
- 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>]
546
- [--allow-workflow-changes] [--allow-mass-delete] [--title "<t>"] [--force "<reason>"]`
547
-
548
- /** `argv` starts at the subcommand: ['branch', 'fix-x', ...] or ['pr', 'fix-x', ...]. Returns an exit code. */
549
- export async function main(argv, { cwd = process.cwd(), log = console.log, logErr = console.error } = {}) {
550
- const [cmd, ...rest] = argv
551
- const { pos, f } = flags(rest)
552
- const name = pos[0]
553
- if (!name || (cmd !== 'branch' && cmd !== 'pr')) { logErr(USAGE); return 2 }
554
- try {
555
- if (cmd === 'pr') {
556
- const { landAndOpen, testCommandFor, saveTestCommand } = await import('./hive-land.js')
557
- let test = f.test || ''
558
- // --test-copy a,b and --test-setup "<cmd>": what the tests need beyond git (see hive-land.js).
559
- const testCopy = 'test-copy' in f ? String(f['test-copy'] || '').split(',').map((s) => s.trim()).filter(Boolean) : undefined
560
- const testSetup = 'test-setup' in f ? String(f['test-setup'] || '') : undefined
561
- if (f.save) {
562
- if (!test) { logErr(`--save keeps a test command: pass it with --test "<command>"`); return 2 }
563
- saveTestCommand(cwd, name, test, { copy: testCopy ?? null, setup: testSetup ?? null })
564
- 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.`)
565
- }
566
- // No --test: the command saved for this branch, if any. (The project's own `npm test` is the
567
- // hive_pr tool's fallback; here the developer is present, so nothing runs they did not choose.)
568
- if (!test) { const t = testCommandFor(cwd, name); if (t.from === 'saved') { test = t.command; log(`tests: \`${test}\` (saved for ${name})`) } }
569
- const r = await landAndOpen({
570
- 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 || '',
571
- test, testMinutes: Number(f['test-minutes']) || 0, testCopy, testSetup, allowWorkflowChanges: !!f['allow-workflow-changes'], allowMassDelete: !!f['allow-mass-delete'],
572
- by: youFrom(projectRootOrNull(cwd) || cwd, ''), log, logErr,
573
- })
574
- if (!r.ok) { logErr(r.why); return 1 }
575
- return 0
576
- }
577
- const r = await bindBranch({ cwd, branch: name, from: f.from || '', relay: f.relay || '', you: f.name || '', join: f.join || '', reseed: !!f.reseed })
578
- 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)`
579
- if (r.joined) {
580
- log(`Recorded: branch ${r.branch} in ${r.root} is the room ${r.link}\n\nTell each of your agents:\n ${agentLine}`)
581
- return 0
582
- }
583
- if (r.existing) {
584
- 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)`}`)
585
- 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`)
586
- } else {
587
- const dirty = git(r.root, ['status', '--porcelain'])
588
- const dirtyLines = dirty.out ? dirty.out.split('\n').filter(Boolean) : []
589
- const dirtyFiles = dirtyLines.map((l) => {
590
- const p = l.slice(3).trim()
591
- return p.includes(' -> ') ? p.split(' -> ')[1].trim() : p
592
- }).filter(Boolean)
593
- const shown = dirtyFiles.slice(0, 5).join(', ')
594
- const more = dirtyFiles.length > 5 ? ` (+${dirtyFiles.length - 5} more)` : ''
595
- const uncommittedPart = dirtyFiles.length ? `\n ${dirtyFiles.length} file(s) with uncommitted changes are not in the room: ${shown}${more}` : ''
596
- 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}`)
597
- if (/onrender\.com/.test(r.link)) log(' This is the public relay: anyone who has this link can read these files.')
598
- const roomName = parseLink(r.link).room
599
- 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.`)
600
- }
601
- 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`)
602
- return 0
603
- } catch (e) {
604
- logErr(e && e.message ? e.message : String(e))
605
- return 1
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
+ /** The branch a PR from `name` goes into: the checkout's current branch, else origin's default. */
389
+ function defaultBase(root, name) {
390
+ const cur = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
391
+ if (cur.code === 0 && cur.out && cur.out !== name) return cur.out
392
+ const o = git(root, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
393
+ const d = o.code === 0 && o.out ? o.out.replace(/^origin\//, '') : ''
394
+ return d !== name ? d : ''
395
+ }
396
+
397
+ /**
398
+ * Delete a room folder ONLY if it is one this tool made: under HIVE_HOME, carrying our `.git`
399
+ * line, and naming this room in its .hive.json. The path comes from git config, and config is
400
+ * editable text -- a path that fails any of these is refused, never removed.
401
+ */
402
+ export function removeRoomFolder(dir, room) {
403
+ const abs = path.resolve(dir)
404
+ const home = path.resolve(hiveHome()) + path.sep
405
+ let ours = false
406
+ try {
407
+ ours = abs.startsWith(home) &&
408
+ toLF(fs.readFileSync(path.join(abs, '.git'), 'utf8')) === NOT_A_CHECKOUT &&
409
+ JSON.parse(fs.readFileSync(path.join(abs, '.hive.json'), 'utf8')).room === room
410
+ } catch { ours = false }
411
+ if (!fs.existsSync(abs)) return
412
+ 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.`)
413
+ fs.rmSync(abs, { recursive: true, force: true })
414
+ }
415
+
416
+ const projectRootOrNull = (cwd) => { try { return projectRoot(cwd) } catch { return null } }
417
+
418
+ // THIS MACHINE'S RECORD OF WHICH REPO HOLDS WHICH ROOM (hive-repos.js), so an agent that joined by
419
+ // link -- not by branch -- can still land from here with hive_pr. Imported late: hive-repos imports
420
+ // this module. Best effort: a registry that cannot be written never fails a bind, and hive_pr then
421
+ // says exactly that the room is not bound on this machine (Q583), with the command that fixes it.
422
+ async function registerLocal(root, branch, link) {
423
+ try { const R = await import('./hive-repos.js'); return !!R.registerRepo({ root, branch, link }) } catch { return false }
424
+ }
425
+ function youFrom(root, given) {
426
+ const raw = given || git(root, ['config', 'user.name']).out || os.userInfo().username || 'dev'
427
+ return validateMemberName(raw).ok ? raw : suggestMemberName(raw)
428
+ }
429
+
430
+ /**
431
+ * `branch <name>`: bind a room to a branch, creating and seeding both on first use.
432
+ * The developer's checkout is never switched or written: the branch is made with `git branch`,
433
+ * and the seed is read out of git objects, not out of the working tree.
434
+ */
435
+ export async function bindBranch({ cwd, branch, from = '', relay = '', you = '', join = '', reseed = false, log = () => {} }) {
436
+ const root = projectRoot(cwd)
437
+ const name = validBranch(root, branch)
438
+ const me = youFrom(root, you)
439
+ const existing = readBinding(root, name)
440
+
441
+ if (join) {
442
+ const { relay: jr, room } = parseLink(join)
443
+ if (!jr || !room) throw coded('Q567', `"${join}" is not a room link (it looks like wss://relay|room-...).`)
444
+ 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}"`)
445
+ const seed = await roomSeed(join, me)
446
+ writeBinding(root, name, { room: join, base: seed && seed.commit ? seed.commit : '' })
447
+ await registerLocal(root, name, join)
448
+ return { root, branch: name, link: join, you: me, joined: true, base: seed && seed.commit ? seed.commit : '' }
449
+ }
450
+
451
+ if (existing && !reseed) {
452
+ const n = await roomFileCount(existing.link, me)
453
+ // Empty is only news when the seed was not: a branch with nothing a room can carry seeds nothing.
454
+ const lost = n === 0 && Number(existing.files || 1) > 0
455
+ await registerLocal(root, name, existing.link) // a binding made before the registry existed gets its entry
456
+ return { root, branch: name, link: existing.link, you: me, from: existing.from, existing: true, roomFiles: n, lost }
457
+ }
458
+
459
+ const relayUrl = currentRelayFor(relay || process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY).relay
460
+ let created = false
461
+ let tip = revParse(root, `refs/heads/${name}`)
462
+ // The PR's base is a BRANCH NAME for `gh`: a sha names no branch, and origin/main means main.
463
+ let fromName = from && !/^[0-9a-f]{7,40}$/i.test(from) ? from.replace(/^origin\//, '') : ''
464
+ if (!tip) {
465
+ const src = from || 'HEAD'
466
+ const sha = revParse(root, `${src}^{commit}`)
467
+ if (!sha) {
468
+ if (src === 'HEAD' && !revParse(root, 'HEAD')) {
469
+ 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.`)
470
+ }
471
+ throw coded('Q562', `"${src}" is not a commit in ${root}.`)
472
+ }
473
+ const r = git(root, ['branch', '--no-track', name, sha]) // `branch`, never `checkout -b`: the checkout stays where it is
474
+ if (r.code !== 0) throw coded('Q562', `could not create branch ${name}: ${r.err}`)
475
+ tip = sha
476
+ created = true
477
+ }
478
+ if (!fromName) fromName = defaultBase(root, name)
479
+
480
+ let link = existing && existing.link
481
+ if (reseed) {
482
+ if (!existing) throw coded('Q562', `branch "${name}" has no room to reseed; run without --reseed to create one.`)
483
+ const n = await roomFileCount(existing.link, me)
484
+ if (n === null) throw coded('Q565', `could not reach the relay behind ${existing.link}.`)
485
+ 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.`)
486
+ // A fresh seed folder: the old one's record of what it last wrote belongs to the lost room.
487
+ removeRoomFolder(existing.seed || roomFolder(existing.link, me), parseLink(existing.link).room)
488
+ if (existing.seed && path.resolve(existing.seed) !== path.resolve(roomFolder(existing.link, me))) removeRoomFolder(roomFolder(existing.link, me), parseLink(existing.link).room)
489
+ } else {
490
+ link = `${relayUrl}|room-${crypto.randomBytes(13).toString('base64url')}`
491
+ }
492
+
493
+ let seeded
494
+ try {
495
+ seeded = await seedRoom({ root, branch: name, commit: tip, link, you: me, log })
496
+ } catch (e) {
497
+ // A refused bind leaves nothing behind: not a binding, not a branch nobody asked to keep, and
498
+ // not a half-seeded folder for the next attempt (which gets a new room) to trip over.
499
+ if (created && revParse(root, `refs/heads/${name}`) === tip) git(root, ['branch', '-D', name])
500
+ try { removeRoomFolder(roomFolder(link, me), parseLink(link).room) } catch { /* not ours to remove */ }
501
+ throw e
502
+ }
503
+ writeBinding(root, name, { room: link, from: fromName, base: tip, seed: seeded.dir, files: String(seeded.count) })
504
+ await registerLocal(root, name, link)
505
+ return { root, branch: name, link, you: me, from: fromName, commit: tip, created, reseeded: reseed, files: seeded.count, total: seeded.total, seedDir: seeded.dir }
506
+ }
507
+
508
+ // ---- landing: the room's work becomes commits on the branch ---------------------------------
509
+
510
+ /**
511
+ * `pr <name>` used to land here. Landing now has ONE path, hive-land.js landAndOpen, shared by the
512
+ * `pr` command, the hive_pr tool and the GitHub watcher: every check (claims, an empty room, workflow
513
+ * files, keys, tests, origin ahead, the lock) protects all three. Two copies of the landing had
514
+ * already drifted once (the deletion accounting lived only here).
515
+ *
516
+ * landBranch keeps its old contract for the watcher, which still runs its own workflow, key and test
517
+ * checks after landing, and whose agent has exited by the time it lands (so its claims are moot):
518
+ * { ok:true, landed, tip, deletions } | { ok:false, code } with the reason on logErr.
519
+ */
520
+ export async function landBranch({ cwd, branch, dry = false, push = false, pr = false, base = '', force = '', only = '', title = '', log = console.log, logErr = console.error }) {
521
+ const { landAndOpen } = await import('./hive-land.js')
522
+ 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' })
523
+ if (!r.ok) { logErr(r.why); return { ok: false, code: 1, refused: r.code } }
524
+ return r.dry ? { ok: true, dry: true, deletions: r.deletions } : { ok: true, landed: r.landed, tip: r.tip, deletions: r.deletions }
525
+ }
526
+
527
+ // ---- CLI ----------------------------------------------------------------------------------
528
+
529
+ function flags(argv) {
530
+ const pos = []
531
+ const f = {}
532
+ for (let i = 0; i < argv.length; i++) {
533
+ const a = argv[i]
534
+ if (!a.startsWith('--')) { pos.push(a); continue }
535
+ const k = a.slice(2)
536
+ if (['dry-run', 'push', 'pr', 'reseed', 'allow-workflow-changes', 'allow-mass-delete', 'save'].includes(k)) f[k] = true
537
+ else f[k] = argv[++i] || ''
538
+ }
539
+ return { pos, f }
540
+ }
541
+
542
+ const USAGE = `usage:
543
+ npx -y hivecode-mcp@latest branch <name> [--from <ref>] [--name <you>] [--relay <url>]
544
+ npx -y hivecode-mcp@latest branch <name> --join "<link>"
545
+ npx -y hivecode-mcp@latest branch <name> --reseed
546
+ 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>]
547
+ [--allow-workflow-changes] [--allow-mass-delete] [--title "<t>"] [--force "<reason>"]`
548
+
549
+ /** `argv` starts at the subcommand: ['branch', 'fix-x', ...] or ['pr', 'fix-x', ...]. Returns an exit code. */
550
+ export async function main(argv, { cwd = process.cwd(), log = console.log, logErr = console.error } = {}) {
551
+ const [cmd, ...rest] = argv
552
+ const { pos, f } = flags(rest)
553
+ const name = pos[0]
554
+ if (!name || (cmd !== 'branch' && cmd !== 'pr')) { logErr(USAGE); return 2 }
555
+ try {
556
+ if (cmd === 'pr') {
557
+ const { landAndOpen, testCommandFor, saveTestCommand } = await import('./hive-land.js')
558
+ let test = f.test || ''
559
+ // --test-copy a,b and --test-setup "<cmd>": what the tests need beyond git (see hive-land.js).
560
+ const testCopy = 'test-copy' in f ? String(f['test-copy'] || '').split(',').map((s) => s.trim()).filter(Boolean) : undefined
561
+ const testSetup = 'test-setup' in f ? String(f['test-setup'] || '') : undefined
562
+ if (f.save) {
563
+ if (!test) { logErr(`--save keeps a test command: pass it with --test "<command>"`); return 2 }
564
+ saveTestCommand(cwd, name, test, { copy: testCopy ?? null, setup: testSetup ?? null })
565
+ 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.`)
566
+ }
567
+ // No --test: the command saved for this branch, if any. (The project's own `npm test` is the
568
+ // hive_pr tool's fallback; here the developer is present, so nothing runs they did not choose.)
569
+ if (!test) { const t = testCommandFor(cwd, name); if (t.from === 'saved') { test = t.command; log(`tests: \`${test}\` (saved for ${name})`) } }
570
+ const r = await landAndOpen({
571
+ 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 || '',
572
+ test, testMinutes: Number(f['test-minutes']) || 0, testCopy, testSetup, allowWorkflowChanges: !!f['allow-workflow-changes'], allowMassDelete: !!f['allow-mass-delete'],
573
+ by: youFrom(projectRootOrNull(cwd) || cwd, ''), log, logErr,
574
+ })
575
+ if (!r.ok) { logErr(r.why); return 1 }
576
+ return 0
577
+ }
578
+ const r = await bindBranch({ cwd, branch: name, from: f.from || '', relay: f.relay || '', you: f.name || '', join: f.join || '', reseed: !!f.reseed })
579
+ 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)`
580
+ if (r.joined) {
581
+ log(`Recorded: branch ${r.branch} in ${r.root} is the room ${r.link}\n\nTell each of your agents:\n ${agentLine}`)
582
+ return 0
583
+ }
584
+ if (r.existing) {
585
+ 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)`}`)
586
+ 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`)
587
+ } else {
588
+ const dirty = git(r.root, ['status', '--porcelain'])
589
+ const dirtyLines = dirty.out ? dirty.out.split('\n').filter(Boolean) : []
590
+ const dirtyFiles = dirtyLines.map((l) => {
591
+ const p = l.slice(3).trim()
592
+ return p.includes(' -> ') ? p.split(' -> ')[1].trim() : p
593
+ }).filter(Boolean)
594
+ const shown = dirtyFiles.slice(0, 5).join(', ')
595
+ const more = dirtyFiles.length > 5 ? ` (+${dirtyFiles.length - 5} more)` : ''
596
+ const uncommittedPart = dirtyFiles.length ? `\n ${dirtyFiles.length} file(s) with uncommitted changes are not in the room: ${shown}${more}` : ''
597
+ 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}`)
598
+ if (/onrender\.com/.test(r.link)) log(' This is the public relay: anyone who has this link can read these files.')
599
+ const roomName = parseLink(r.link).room
600
+ 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.`)
601
+ }
602
+ 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`)
603
+ return 0
604
+ } catch (e) {
605
+ logErr(e && e.message ? e.message : String(e))
606
+ return 1
607
+ }
608
+ }