hivecode-mcp 0.12.4 → 0.13.1

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.
@@ -0,0 +1,44 @@
1
+ // hive-authors.js -- who wrote what the room holds: the ONE authorsOf, shared by sync.js and hive-pr.js. A receipt counts only when verify(r) === true or verify(r).ok === true (a truthy {ok:false} used to count).
2
+ import { contentHash } from './substrate.js'
3
+
4
+ /**
5
+ * Check whether a receipt is valid according to the optional verify function.
6
+ * Handles both boolean returns and { ok: boolean } result objects (verifyReceipt).
7
+ */
8
+ export function isReceiptValid(verify, r) {
9
+ if (!verify) return true
10
+ const v = verify(r)
11
+ return v === true || Boolean(v && v.ok)
12
+ }
13
+
14
+ /**
15
+ * The authors of a file's held content, newest-landed first, from verified receipts only.
16
+ */
17
+ export function authorsOf(receipts, verify, roomText) {
18
+ const ok = (receipts || []).filter((r) => r && r.author && isReceiptValid(verify, r))
19
+ if (!ok.length) return { last: null, contributors: [], first: 0, receipts: [] }
20
+ const want = typeof roomText === 'string' ? contentHash(roomText) : null
21
+ let idx = -1
22
+ if (want) {
23
+ for (let i = ok.length - 1; i >= 0; i--) {
24
+ if (ok[i].contentHash === want) {
25
+ idx = i
26
+ break
27
+ }
28
+ }
29
+ }
30
+ const held = idx >= 0 ? ok.slice(0, idx + 1) : ok
31
+ const last = held[held.length - 1]
32
+ const seen = new Map()
33
+ for (const r of held) {
34
+ if (!seen.has(r.author)) {
35
+ seen.set(r.author, { author: r.author, name: r.name || 'agent', at: r.at || 0 })
36
+ }
37
+ }
38
+ return {
39
+ last: last ? { author: last.author, name: last.name || 'agent', at: last.at || 0 } : null,
40
+ contributors: [...seen.values()],
41
+ first: Math.min(...ok.map((r) => r.at || 0)),
42
+ receipts: held,
43
+ }
44
+ }
package/hive-await.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // hive-await — block until someone speaks to you, then exit. The CLI's `hive_wait`.
3
3
  //
4
- // npx -y -p hivecode-mcp@latest hivecode-await "<relay|room>" <YourName> [idleMinutes]
4
+ // npx -y -p hivecode-mcp@latest hivecode-await "<relay|room>" <YourName> [idleMinutes] [app]
5
5
  //
6
6
  // Run it in the BACKGROUND and let its exit wake you. Exits 0 in both cases; read `reason` on stdout:
7
7
  // {"reason":"mention","messages":[...]} someone said @YourName or @all -> read, reply, run it again
@@ -37,15 +37,20 @@ import { startSync, parseLink } from './sync.js'
37
37
  import * as RC from './hive-receipts.js'
38
38
  import { awaitDir, armedFile, BEAT_MS } from './hive-listen.js'
39
39
 
40
- const [, , LINK = '', NAME = '', IDLE_MIN = '10'] = process.argv
40
+ const [, , LINK = '', NAME = '', IDLE_MIN = '10', APP_ARG = ''] = process.argv
41
41
  if (!LINK || !NAME) {
42
- console.error('usage: npx -y -p hivecode-mcp@latest hivecode-await "<relay|room>" <YourName> [idleMinutes]')
42
+ console.error('usage: npx -y -p hivecode-mcp@latest hivecode-await "<relay|room>" <YourName> [idleMinutes] [app]')
43
43
  process.exit(2)
44
44
  }
45
45
  const { relay, room } = parseLink(LINK)
46
46
  const dir = awaitDir(NAME)
47
+ // Which app will be woken when this listener exits: passed by listenCommand, or inferred from the
48
+ // session environment when the agent ran `node hive-await.js <link> <name> <min>` directly. Without
49
+ // `app` here, a listener running while the MCP connection is closed or restarting leaves `app`
50
+ // undefined in awareness, and hive-activity.js misreports the agent as "not woken by it".
51
+ const APP = APP_ARG || process.env.HIVE_APP || (process.env.CLAUDE_CODE_SESSION_ID ? 'claude-code' : (process.env.ANTIGRAVITY_CONVERSATION_ID || process.env.ANTIGRAVITY_AGENT ? 'antigravity' : ''))
47
52
 
48
- const hive = startSync({ relay, room, dir, name: NAME, kind: 'ai', token: process.env.HIVE_TOKEN || '', syncFiles: false, listener: true, log: () => {} })
53
+ const hive = startSync({ relay, room, dir, name: NAME, kind: 'ai', token: process.env.HIVE_TOKEN || '', syncFiles: false, listener: true, app: APP, log: () => {} })
49
54
 
50
55
  // ARMED, AND THE NEWEST LISTENER WINS (4 Oct 2026; see hive-listen.js). This file is how the Stop
51
56
  // hook and the MCP server know, with no network call, that the agent will hear the room after its
@@ -55,20 +60,40 @@ const hive = startSync({ relay, room, dir, name: NAME, kind: 'ai', token: proces
55
60
  // listener that finds the file naming another process leaves: that one was started after it.
56
61
  const ARMED = armedFile(NAME, room)
57
62
  const SESSION = process.env.CLAUDE_CODE_SESSION_ID || process.env.ANTIGRAVITY_CONVERSATION_ID || ''
58
- const AGAIN = `"${process.execPath.replace(/\\/g, '/')}" "${path.resolve(process.argv[1]).replace(/\\/g, '/')}" "${LINK}" "${NAME}" ${IDLE_MIN}`
63
+ const AGAIN = `"${process.execPath.replace(/\\/g, '/')}" "${path.resolve(process.argv[1]).replace(/\\/g, '/')}" "${LINK}" "${NAME}" ${IDLE_MIN}${APP_ARG ? ` "${APP_ARG}"` : ''}`
59
64
  const startedAt = Date.now()
60
65
  let synced = false
61
- const ownerPid = () => { try { return Number(JSON.parse(fs.readFileSync(ARMED, 'utf8')).pid) || 0 } catch { return 0 } }
66
+ const readArmed = () => { try { return JSON.parse(fs.readFileSync(ARMED, 'utf8')) } catch { return null } }
67
+ const ownerPid = () => Number((readArmed() || {}).pid) || 0
68
+ const alive = (pid) => { try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' } }
69
+ // THE NEWEST BY START TIME WINS, NOT THE LAST ONE TO WRITE (6 Oct 2026, the first macOS run). Every
70
+ // listener reads the file and then rewrites it on each beat. An older listener that read it just
71
+ // before a newer one wrote it put its own claim back on top, and the NEWER one then left as
72
+ // "superseded" while the older stayed: N1 and N3 failed on macOS, never on Windows. So a claim is
73
+ // judged by when its listener started. A listener yields only to one that started after it and is
74
+ // still running; a newer one that finds an older claim on top takes the file back on its next beat.
75
+ // The file is also written whole (a temp file renamed over it), so a reader never sees half of one.
76
+ const newerThanMe = (rec) => {
77
+ const pid = Number(rec && rec.pid) || 0
78
+ if (!pid || pid === process.pid || !alive(pid)) return false
79
+ const at = Number(rec.at) || 0
80
+ return at > startedAt || (at === startedAt && pid > process.pid)
81
+ }
62
82
  function beat() {
63
83
  try {
64
84
  fs.mkdirSync(path.dirname(ARMED), { recursive: true })
65
- fs.writeFileSync(ARMED, JSON.stringify({ pid: process.pid, name: NAME, room, link: LINK, session: SESSION, at: startedAt, beat: Date.now(), synced }))
85
+ const body = JSON.stringify({ pid: process.pid, name: NAME, room, link: LINK, session: SESSION, ...(APP ? { app: APP } : {}), at: startedAt, beat: Date.now(), synced })
86
+ const part = `${ARMED}.${process.pid}.tmp`
87
+ try { fs.writeFileSync(part, body); fs.renameSync(part, ARMED) } catch {
88
+ try { fs.rmSync(part, { force: true }) } catch {}
89
+ fs.writeFileSync(ARMED, body)
90
+ }
66
91
  } catch { /* unarmed is the safe reading: the hook asks for a fresh listener, and it replaces this one */ }
67
92
  }
68
93
  const superseded = () => {
69
- const o = ownerPid()
70
- if (!o || o === process.pid) return false
71
- finish('superseded', { note: `a newer listener for ${NAME} in ${room} took over (pid ${o}). Do not start this one again.` })
94
+ const rec = readArmed()
95
+ if (!newerThanMe(rec)) return false
96
+ finish('superseded', { note: `a newer listener for ${NAME} in ${room} took over (pid ${rec.pid}). Do not start this one again.` })
72
97
  return true
73
98
  }
74
99
  beat()
package/hive-branch.js CHANGED
@@ -31,7 +31,6 @@ import os from 'os'
31
31
  import path from 'path'
32
32
  import crypto from 'crypto'
33
33
  import { spawnSync } from 'child_process'
34
- import { fileURLToPath } from 'url'
35
34
  import ignore from 'ignore'
36
35
  import { startSync, parseLink, ALWAYS_IGNORE, SKIP } from './sync.js'
37
36
  import { PUBLIC_DEFAULT_RELAY, currentRelayFor } from './hive-privacy.js'
@@ -40,6 +39,10 @@ import { validateMemberName, suggestMemberName } from './hive-mailbox.js'
40
39
  import { isSafeRelPath } from './token.js'
41
40
 
42
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 }
43
46
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
44
47
  const coded = (code, msg) => Object.assign(new Error(`${code} ${msg}`), { code })
45
48
 
@@ -58,6 +61,19 @@ export function roomFolder(link, name) {
58
61
 
59
62
  // ---- git ----------------------------------------------------------------------------------
60
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
+
61
77
  function git(cwd, args, { input, raw = false, trim = true } = {}) {
62
78
  const r = spawnSync('git', args, { cwd, input, encoding: raw ? undefined : 'utf8', maxBuffer: 1024 * 1024 * 1024, windowsHide: true })
63
79
  if (r.error) return { code: -1, out: raw ? Buffer.alloc(0) : '', err: r.error.message }
@@ -74,8 +90,14 @@ export function repoRoot(dir) {
74
90
  return r.code === 0 && r.out ? path.resolve(r.out) : null
75
91
  }
76
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).
77
95
  const sameDir = (a, b) => {
78
- const n = (p) => path.resolve(p).replace(/[\\/]+$/, '')
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
+ }
79
101
  return process.platform === 'win32' ? n(a).toLowerCase() === n(b).toLowerCase() : n(a) === n(b)
80
102
  }
81
103
 
@@ -186,7 +208,7 @@ export function roomCarries(files) {
186
208
  // folder look held by some other process, and a foreign shadow is somebody else's ancestry.
187
209
  // By exact name: a project's own `.hiverc` is its business. All of these are already never carried
188
210
  // (always-ignored, or rendered locally), so the seed and a fresh joiner agree on what the room holds.
189
- const OWN_FILES = new Set(['.hive.json', '.hive-shadow.json', '.hive-shadow.json.tmp', '.hive-lock.json', '.hive-id.json', '.hive-mcp-session.json'])
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'])
190
212
  const isOwnFile = (rel) => OWN_FILES.has(path.posix.basename(rel))
191
213
 
192
214
  function writeFiles(dir, files) {
@@ -308,6 +330,13 @@ async function seedRoom({ root, branch, commit, link, you, log }) {
308
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.`)
309
331
  }
310
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 */ }
311
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.`)
312
341
  await sleep(600) // let the announcement leave before the socket closes
313
342
  return { dir, count: expected.size, total: files.length }
@@ -336,6 +365,25 @@ async function roomPaths(link, you) {
336
365
  }
337
366
  const roomFileCount = async (link, you) => { const p = await roomPaths(link, you); return p ? p.size : null }
338
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
+
339
387
  /** The branch a PR from `name` goes into: the checkout's current branch, else origin's default. */
340
388
  function defaultBase(root, name) {
341
389
  const cur = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
@@ -350,7 +398,7 @@ function defaultBase(root, name) {
350
398
  * line, and naming this room in its .hive.json. The path comes from git config, and config is
351
399
  * editable text -- a path that fails any of these is refused, never removed.
352
400
  */
353
- function removeRoomFolder(dir, room) {
401
+ export function removeRoomFolder(dir, room) {
354
402
  const abs = path.resolve(dir)
355
403
  const home = path.resolve(hiveHome()) + path.sep
356
404
  let ours = false
@@ -364,6 +412,15 @@ function removeRoomFolder(dir, room) {
364
412
  fs.rmSync(abs, { recursive: true, force: true })
365
413
  }
366
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
+ }
367
424
  function youFrom(root, given) {
368
425
  const raw = given || git(root, ['config', 'user.name']).out || os.userInfo().username || 'dev'
369
426
  return validateMemberName(raw).ok ? raw : suggestMemberName(raw)
@@ -384,14 +441,17 @@ export async function bindBranch({ cwd, branch, from = '', relay = '', you = '',
384
441
  const { relay: jr, room } = parseLink(join)
385
442
  if (!jr || !room) throw coded('Q567', `"${join}" is not a room link (it looks like wss://relay|room-...).`)
386
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}"`)
387
- writeBinding(root, name, { room: join })
388
- return { root, branch: name, link: join, you: me, joined: true }
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 : '' }
389
448
  }
390
449
 
391
450
  if (existing && !reseed) {
392
451
  const n = await roomFileCount(existing.link, me)
393
452
  // Empty is only news when the seed was not: a branch with nothing a room can carry seeds nothing.
394
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
395
455
  return { root, branch: name, link: existing.link, you: me, from: existing.from, existing: true, roomFiles: n, lost }
396
456
  }
397
457
 
@@ -440,155 +500,27 @@ export async function bindBranch({ cwd, branch, from = '', relay = '', you = '',
440
500
  throw e
441
501
  }
442
502
  writeBinding(root, name, { room: link, from: fromName, base: tip, seed: seeded.dir, files: String(seeded.count) })
503
+ await registerLocal(root, name, link)
443
504
  return { root, branch: name, link, you: me, from: fromName, commit: tip, created, reseeded: reseed, files: seeded.count, total: seeded.total, seedDir: seeded.dir }
444
505
  }
445
506
 
446
507
  // ---- landing: the room's work becomes commits on the branch ---------------------------------
447
508
 
448
- const HIVE_PR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'hive-pr.js')
449
- const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
450
-
451
509
  /**
452
- * THE ROOM'S DELETIONS, which hive-pr does not land: it copies the files a room holds, so a file an
453
- * agent deleted would simply stay on the branch, in a PR that looks complete. A room bound to a
454
- * branch starts as the whole branch, so "on the branch but no longer in the room" is a deletion --
455
- * but only when the room provably held THAT version of the file. For each such path:
456
- * - last changed on the branch by a landing from this room (its commit says "Room: <id>") -> delete
457
- * - unchanged since the seed commit -> delete
458
- * - in the seed, but changed on the branch since by someone else -> REFUSE (Q572):
459
- * deleting it would erase a change the room never saw
460
- * - not in the seed and not landed from this room: added to the branch outside the room -> keep
461
- */
462
- async function roomDeletions({ root, link, base, tip }) {
463
- if (!base) return []
464
- const paths = await roomPaths(link, 'hive-land')
465
- if (!paths) throw coded('Q565', `could not reach the relay to read the room's file list, so its deletions cannot be accounted for. Nothing was changed.`)
466
- const room = parseLink(link).room
467
- const atTip = treeFiles(root, tip)
468
- const carries = roomCarries(atTip)
469
- const baseSha = new Map(treeFiles(root, base).map((f) => [f.rel, f.sha]))
470
- const gone = [], diverged = []
471
- for (const f of atTip) {
472
- if (!carries(f) || paths.has(f.rel)) continue
473
- const last = git(root, ['log', '-1', '--format=%B', tip, '--', f.rel]).out
474
- if (new RegExp(`^Room: ${reEsc(room)}$`, 'm').test(last)) gone.push(f.rel)
475
- else if (baseSha.get(f.rel) === f.sha) gone.push(f.rel)
476
- else if (baseSha.has(f.rel)) diverged.push(f.rel)
477
- }
478
- if (diverged.length) {
479
- const who = (rel) => git(root, ['log', '-1', '--format=%h %an %ad %s', '--date=short', tip, '--', rel]).out
480
- throw coded('Q572', `the room deleted ${diverged.length} file(s) that were changed on the branch since it was seeded, by a change the room never saw:\n${diverged.map((r) => ` ${r} last changed by: ${who(r)}`).join('\n')}\nDeleting them would erase that change. Nothing was changed. Restore the file in the room (or delete it on the branch yourself), then run pr again.`)
481
- }
482
- return gone
483
- }
484
-
485
- function worktreesOn(root, branch) {
486
- const r = git(root, ['worktree', 'list', '--porcelain'])
487
- const out = []
488
- let cur = null
489
- for (const line of r.out.split('\n')) {
490
- if (line.startsWith('worktree ')) cur = line.slice(9)
491
- else if (line === `branch refs/heads/${branch}` && cur) out.push(path.resolve(cur))
492
- }
493
- return out
494
- }
495
-
496
- /**
497
- * `pr <name>`: land the room's work on the bound branch as one commit per agent.
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).
498
514
  *
499
- * hive-pr writes the commits (attribution and the staleness check are all its own), but it runs in
500
- * a TEMPORARY WORKTREE detached at the branch tip, because hive-pr switches the checkout it is given
501
- * and the developer's must never be switched. The branch then moves by compare-and-swap from the tip
502
- * the worktree was cut from, so a commit that reached the branch meanwhile is refused, never lost.
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.
503
518
  */
504
519
  export async function landBranch({ cwd, branch, dry = false, push = false, pr = false, base = '', force = '', only = '', title = '', log = console.log, logErr = console.error }) {
505
- const root = projectRoot(cwd)
506
- const name = validBranch(root, branch)
507
- const b = readBinding(root, name)
508
- if (!b) throw coded('Q562', `branch "${name}" has no Hivecode room here. Create one with: npx -y hivecode-mcp@latest branch ${name}`)
509
- const tip = revParse(root, `refs/heads/${name}`)
510
- if (!tip) throw coded('Q563', `branch "${name}" does not exist in ${root}. Fetch it first: git fetch origin ${name}:${name}`)
511
-
512
- const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-land-'))
513
- const temp = `hivecode-land/${safeSegment(name)}-${process.pid}-${Date.now()}`
514
- const cleanup = () => {
515
- git(root, ['worktree', 'remove', '--force', tmp])
516
- try { fs.rmSync(tmp, { recursive: true, force: true }) } catch { }
517
- git(root, ['worktree', 'prune'])
518
- if (revParse(root, `refs/heads/${temp}`)) git(root, ['branch', '-D', temp])
519
- }
520
- const wt = git(root, ['worktree', 'add', '--detach', tmp, tip])
521
- if (wt.code !== 0) { cleanup(); throw coded('Q563', `could not make a temporary worktree: ${wt.err}`) }
522
-
523
- try {
524
- // Accounted for BEFORE anything is written, so a refusal (Q572) leaves the branch as it was.
525
- const deletions = await roomDeletions({ root, link: b.link, base: b.base, tip })
526
-
527
- const args = [HIVE_PR, b.link, 'hive-pr', '--repo', tmp, '--branch', temp, '--base', 'HEAD']
528
- if (dry) args.push('--dry-run')
529
- if (force) args.push('--force', force)
530
- if (only) args.push('--only', only)
531
- const r = spawnSync(process.execPath, args, { cwd: tmp, encoding: 'utf8', env: { ...process.env, HIVE_PROVENANCE: 'on' }, maxBuffer: 64 * 1024 * 1024, windowsHide: true })
532
- if (r.stdout) log(r.stdout.replace(/\s+$/, ''))
533
- // Q501 is hive-pr saying no file in the room carries an agent's receipt: nothing was changed or
534
- // added. For a branch room that is an answer, not a failure -- and deletions may still land.
535
- const nothingChanged = r.status !== 0 && /^Q501 /m.test(r.stderr || '')
536
- if (r.stderr && !nothingChanged) logErr(r.stderr.replace(/\s+$/, ''))
537
- if (r.status !== 0 && !nothingChanged) return { ok: false, code: r.status || 1 }
538
- if (dry) {
539
- if (deletions.length) log(`would delete ${deletions.length} file(s) the room deleted: ${deletions.join(', ')}`)
540
- return { ok: true, dry: true, deletions }
541
- }
542
-
543
- if (deletions.length) {
544
- // hive-pr left the temporary worktree on its branch when it committed; when it had nothing to
545
- // commit it never made one, so make it here, at the same tip.
546
- if (!revParse(root, `refs/heads/${temp}`)) git(tmp, ['checkout', '-q', '-b', temp])
547
- const rm = git(tmp, ['rm', '-q', '--', ...deletions])
548
- if (rm.code !== 0) throw coded('Q563', `could not stage the room's deletions: ${rm.err}`)
549
- const msg = [`Room: delete ${deletions.length === 1 ? deletions[0] : `${deletions.length} files`}`, '', ...deletions.map((d) => ` - ${d}`), '', `Room: ${parseLink(b.link).room}`].join('\n')
550
- const c = git(tmp, ['commit', '-q', '-m', msg, '--author', 'hivecode-room <room@agents.hivecode.local>'])
551
- if (c.code !== 0) throw coded('Q563', `could not commit the room's deletions: ${c.err || c.out}`)
552
- }
553
- if (nothingChanged && !deletions.length) { log(`nothing to land yet: no agent has changed a file in the room for ${name}.`); return { ok: true, landed: 0 } }
554
-
555
- const newTip = revParse(root, `refs/heads/${temp}`)
556
- if (!newTip || newTip === tip) { log(`nothing to land: ${name} already has everything the room holds.`); return { ok: true, landed: 0 } }
557
-
558
- // Move the branch. Checked out somewhere: only a clean fast-forward of that checkout, which is
559
- // what `git pull` would do; with local changes it is refused, because moving the branch under a
560
- // dirty checkout makes its edits look like reversals of the landed work.
561
- const on = worktreesOn(root, name).filter((p) => p !== path.resolve(tmp))
562
- if (on.length) {
563
- const where = on[0]
564
- const dirty = git(where, ['status', '--porcelain', '--untracked-files=no'])
565
- if (dirty.out) throw coded('Q571', `${name} is checked out in ${where} with uncommitted changes. Commit or stash them there (or switch that checkout to another branch), then run pr again. Nothing was changed.`)
566
- const ff = git(where, ['merge', '--ff-only', '--quiet', newTip])
567
- if (ff.code !== 0) throw coded('Q570', `could not fast-forward ${name} in ${where}: ${ff.err || 'git refused'}. Nothing was changed; if the branch moved there, run pr again.`)
568
- } else {
569
- const cas = git(root, ['update-ref', '-m', 'hivecode: land room work', `refs/heads/${name}`, newTip, tip])
570
- if (cas.code !== 0) throw coded('Q570', `${name} moved while this was landing (it is no longer ${tip.slice(0, 7)}). Nothing was changed; run pr again so the new commits are accounted for.`)
571
- }
572
- const made = git(root, ['log', '--format= %h %an %s', `${tip}..${newTip}`]).out
573
- log(`\nlanded on ${name}:\n${made}`)
574
-
575
- if (push) {
576
- const p = git(root, ['push', '-u', 'origin', name])
577
- if (p.code !== 0) throw coded('Q573', `the work landed on ${name} locally, but the push failed: ${p.err}`)
578
- log(`pushed ${name}`)
579
- }
580
- if (pr) {
581
- const into = base || b.from
582
- if (!into) throw coded('Q574', `the work landed on ${name}, but there is no branch to open the PR into; pass --base <branch>.`)
583
- const body = [`Opened by Hivecode from the room bound to \`${name}\`.`, '', 'Each commit carries its agent as the git author and the signed author id as a `Hivecode-Author` trailer.', '', '```', made, '```'].join('\n')
584
- const g = spawnSync('gh', ['pr', 'create', '--base', into, '--head', name, '--title', title || `Hivecode: ${name}`, '--body', body], { cwd: root, encoding: 'utf8', windowsHide: true })
585
- if (g.status !== 0) throw coded('Q574', `the work landed on ${name}, but gh pr create failed: ${(g.stderr || (g.error && g.error.message) || '').toString().trim()}`)
586
- log((g.stdout || '').trim())
587
- }
588
- return { ok: true, landed: made.split('\n').filter(Boolean).length, tip: newTip, deletions }
589
- } finally {
590
- cleanup()
591
- }
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 }
592
524
  }
593
525
 
594
526
  // ---- CLI ----------------------------------------------------------------------------------
@@ -600,7 +532,7 @@ function flags(argv) {
600
532
  const a = argv[i]
601
533
  if (!a.startsWith('--')) { pos.push(a); continue }
602
534
  const k = a.slice(2)
603
- if (['dry-run', 'push', 'pr', 'reseed'].includes(k)) f[k] = true
535
+ if (['dry-run', 'push', 'pr', 'reseed', 'allow-workflow-changes', 'allow-mass-delete', 'save'].includes(k)) f[k] = true
604
536
  else f[k] = argv[++i] || ''
605
537
  }
606
538
  return { pos, f }
@@ -610,7 +542,8 @@ const USAGE = `usage:
610
542
  npx -y hivecode-mcp@latest branch <name> [--from <ref>] [--name <you>] [--relay <url>]
611
543
  npx -y hivecode-mcp@latest branch <name> --join "<link>"
612
544
  npx -y hivecode-mcp@latest branch <name> --reseed
613
- npx -y hivecode-mcp@latest pr <name> [--dry-run] [--push] [--pr] [--base <branch>] [--force "<reason>"]`
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>"]`
614
547
 
615
548
  /** `argv` starts at the subcommand: ['branch', 'fix-x', ...] or ['pr', 'fix-x', ...]. Returns an exit code. */
616
549
  export async function main(argv, { cwd = process.cwd(), log = console.log, logErr = console.error } = {}) {
@@ -620,8 +553,26 @@ export async function main(argv, { cwd = process.cwd(), log = console.log, logEr
620
553
  if (!name || (cmd !== 'branch' && cmd !== 'pr')) { logErr(USAGE); return 2 }
621
554
  try {
622
555
  if (cmd === 'pr') {
623
- const r = await landBranch({ 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 || '', log, logErr })
624
- return r.ok ? 0 : r.code || 1
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
625
576
  }
626
577
  const r = await bindBranch({ cwd, branch: name, from: f.from || '', relay: f.relay || '', you: f.name || '', join: f.join || '', reseed: !!f.reseed })
627
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)`