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.
- package/HIVE_RULES.md +11 -40
- package/README.md +421 -346
- package/docs/ALWAYS_ON.md +310 -0
- package/docs/GITHUB_WORKFLOW.md +394 -0
- package/docs/MCP.md +157 -118
- package/docs/QUICKSTART.md +266 -0
- package/docs/SELF_HOSTING.md +2 -2
- package/docs/SLACK_SETUP.md +450 -0
- package/docs/TROUBLESHOOTING.md +105 -0
- package/hive-authors.js +44 -0
- package/hive-await.js +35 -10
- package/hive-branch.js +101 -150
- package/hive-github.js +1944 -0
- package/hive-land.js +691 -0
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +5 -2
- package/hive-mcp-http.js +580 -0
- package/hive-mcp.js +173 -29
- package/hive-notify.js +233 -0
- package/hive-pr.js +205 -29
- package/hive-relay-health.js +230 -230
- package/hive-repos.js +430 -0
- package/hive-room-key.js +209 -0
- package/hive-room-mode.js +1668 -0
- package/hive-room-watcher.js +561 -0
- package/hive-session.js +114 -0
- package/hive-setup.js +14 -4
- package/hive-slack-agent.js +396 -0
- package/hive-slack.js +1809 -0
- package/hive-stop-hook.js +8 -5
- package/hive-time.js +71 -0
- package/hive-webhook.js +54 -0
- package/icr.js +851 -851
- package/limits.js +3 -3
- package/package.json +190 -172
- package/slack-app-manifest-agent.json +61 -0
- package/slack-app-manifest-inbound.json +36 -0
- package/slack-app-manifest.json +27 -0
- package/sync.js +4992 -4691
package/hive-authors.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
70
|
-
if (!
|
|
71
|
-
finish('superseded', { note: `a newer listener for ${NAME} in ${room} took over (pid ${
|
|
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) =>
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
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
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
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
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
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>] [--
|
|
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
|
|
624
|
-
|
|
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)`
|