hivecode-mcp 0.12.2 → 0.13.0
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/LICENSE +21 -21
- package/README.md +421 -167
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -0
- package/docs/GITHUB_WORKFLOW.md +334 -0
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -118
- package/docs/QUICKSTART.md +266 -0
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +450 -0
- package/docs/TROUBLESHOOTING.md +87 -0
- package/hive-activity.js +336 -310
- package/hive-await.js +207 -0
- package/hive-branch.js +686 -0
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +1904 -0
- package/hive-hook-install.js +179 -103
- package/hive-instructions.js +74 -66
- package/hive-listen.js +81 -0
- package/hive-mailbox.js +520 -386
- package/hive-mcp-http.js +580 -0
- package/hive-mcp.js +579 -137
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +350 -350
- package/hive-notify.js +233 -0
- package/hive-obligations.js +247 -247
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +350 -0
- package/hive-privacy.js +327 -288
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +230 -225
- package/hive-room-key.js +209 -0
- package/hive-secrets.js +89 -0
- package/hive-session.js +114 -0
- package/hive-setup.js +558 -445
- package/hive-slack-agent.js +396 -0
- package/hive-slack.js +1809 -0
- package/hive-stdio.js +172 -168
- package/hive-stop-hook.js +293 -241
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -0
- package/hive-update.js +58 -58
- package/hive-version.js +110 -59
- package/hive-webhook.js +54 -0
- package/icr-merge.js +58 -58
- package/icr.js +851 -745
- package/lang-json.js +211 -211
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -394
- package/package.json +189 -164
- package/reconcile.cjs +614 -477
- package/slack-app-manifest-agent.json +61 -0
- package/slack-app-manifest-inbound.json +36 -0
- package/slack-app-manifest.json +27 -0
- package/substrate.js +252 -252
- package/sync.js +1956 -177
- package/grm-link.cjs +0 -141
package/hive-branch.js
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
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 { fileURLToPath } from 'url'
|
|
35
|
+
import ignore from 'ignore'
|
|
36
|
+
import { startSync, parseLink, ALWAYS_IGNORE, SKIP } from './sync.js'
|
|
37
|
+
import { PUBLIC_DEFAULT_RELAY, currentRelayFor } from './hive-privacy.js'
|
|
38
|
+
import { findSecrets } from './hive-secrets.js'
|
|
39
|
+
import { validateMemberName, suggestMemberName } from './hive-mailbox.js'
|
|
40
|
+
import { isSafeRelPath } from './token.js'
|
|
41
|
+
|
|
42
|
+
const TEXT_MAX = 1_000_000 // sync.js MAX_BYTES: a larger file never crosses the relay
|
|
43
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
44
|
+
const coded = (code, msg) => Object.assign(new Error(`${code} ${msg}`), { code })
|
|
45
|
+
|
|
46
|
+
// ---- where things live --------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
// Read at call time, not import time, so a caller (or a test) that sets HIVE_HOME is honoured.
|
|
49
|
+
export const hiveHome = () => process.env.HIVE_HOME || path.join(os.homedir(), 'hivecode')
|
|
50
|
+
// The same folder rule as hive-mcp.js, so a branch room's folders sit beside every other room's.
|
|
51
|
+
function safeSegment(s) {
|
|
52
|
+
const t = String(s || '').replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[.]+/, '_').slice(0, 64) || '_'
|
|
53
|
+
return /^(con|prn|aux|nul|com\d|lpt\d)$/i.test(t) ? '_' + t : t
|
|
54
|
+
}
|
|
55
|
+
export function roomFolder(link, name) {
|
|
56
|
+
return path.join(hiveHome(), safeSegment(parseLink(link).room), safeSegment(name))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---- git ----------------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
// OUR SCRATCH WORKTREES RUN WITHOUT THE DEVELOPER'S GIT HOOKS. A post-checkout hook that exits 1
|
|
62
|
+
// made `pr` fail with Q563 although git had made the worktree (measured 25 Oct 2026, a vexp hook);
|
|
63
|
+
// others run npm install or rewrite files in what is a scratch folder about to be deleted. Only
|
|
64
|
+
// commands inside our own temporary worktrees: the developer's checkout and every push keep their
|
|
65
|
+
// hooks. A hooks path that is never created means "no hooks" on every platform git runs on.
|
|
66
|
+
export const NO_HOOKS_DIR = path.join(os.tmpdir(), 'hivecode-no-hooks-never-created')
|
|
67
|
+
export const noHooks = (args) => ['-c', `core.hooksPath=${NO_HOOKS_DIR}`, ...args]
|
|
68
|
+
/** The same for a child process that runs git itself. Appends to any GIT_CONFIG_* already set. */
|
|
69
|
+
export function noHooksEnv(env = process.env) {
|
|
70
|
+
const n = Number(env.GIT_CONFIG_COUNT) > 0 ? Number(env.GIT_CONFIG_COUNT) : 0
|
|
71
|
+
return { ...env, GIT_CONFIG_COUNT: String(n + 1), [`GIT_CONFIG_KEY_${n}`]: 'core.hooksPath', [`GIT_CONFIG_VALUE_${n}`]: NO_HOOKS_DIR }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function git(cwd, args, { input, raw = false, trim = true } = {}) {
|
|
75
|
+
const r = spawnSync('git', args, { cwd, input, encoding: raw ? undefined : 'utf8', maxBuffer: 1024 * 1024 * 1024, windowsHide: true })
|
|
76
|
+
if (r.error) return { code: -1, out: raw ? Buffer.alloc(0) : '', err: r.error.message }
|
|
77
|
+
let out = r.stdout == null ? (raw ? Buffer.alloc(0) : '') : r.stdout
|
|
78
|
+
if (!raw && trim) out = out.replace(/\s+$/, '')
|
|
79
|
+
return { code: r.status, out, err: String(r.stderr || '').trim() }
|
|
80
|
+
}
|
|
81
|
+
const revParse = (root, ref) => { const r = git(root, ['rev-parse', '--verify', '--quiet', ref]); return r.code === 0 ? r.out : '' }
|
|
82
|
+
|
|
83
|
+
/** The top of the git working tree containing `dir`, or null. A room folder answers null: git refuses there. */
|
|
84
|
+
export function repoRoot(dir) {
|
|
85
|
+
if (!dir || !fs.existsSync(dir)) return null
|
|
86
|
+
const r = git(dir, ['rev-parse', '--show-toplevel'])
|
|
87
|
+
return r.code === 0 && r.out ? path.resolve(r.out) : null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Links are resolved on both sides: git names a repository's top with them resolved, while the home
|
|
91
|
+
// directory comes as written, and on macOS the temp folders sit behind one (6 Oct 2026).
|
|
92
|
+
const sameDir = (a, b) => {
|
|
93
|
+
const n = (p) => {
|
|
94
|
+
let r = path.resolve(p)
|
|
95
|
+
try { r = fs.realpathSync.native(r) } catch { /* a path that cannot be resolved is compared as written */ }
|
|
96
|
+
return r.replace(/[\\/]+$/, '')
|
|
97
|
+
}
|
|
98
|
+
return process.platform === 'win32' ? n(a).toLowerCase() === n(b).toLowerCase() : n(a) === n(b)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The repository a branch room is for. NOT THE HOME DIRECTORY: a folder that is not a repository of
|
|
103
|
+
* its own sits inside an enclosing one if there is one, and a home directory kept under git is the
|
|
104
|
+
* usual enclosing one -- so "the repo" silently becomes $HOME and its committed files would be
|
|
105
|
+
* published to a room. Measured on the development machine, whose home directory is a git repo: a
|
|
106
|
+
* test's "outside any repo" folder under the temp directory bound, branched and seeded the home repo.
|
|
107
|
+
*/
|
|
108
|
+
function projectRoot(cwd) {
|
|
109
|
+
const root = repoRoot(cwd)
|
|
110
|
+
if (!root) throw coded('Q560', `${cwd} is not inside a git repository. Run this in the repo whose branch the room is for.`)
|
|
111
|
+
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.`)
|
|
112
|
+
return root
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validBranch(root, name) {
|
|
116
|
+
// A leading '-' would be read as an option by every git command it is later passed to.
|
|
117
|
+
const r = !name || name.startsWith('-') ? { code: 1 } : git(root, ['check-ref-format', '--branch', name])
|
|
118
|
+
if (r.code !== 0) throw coded('Q561', `"${name}" is not a valid branch name.`)
|
|
119
|
+
return r.out
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The binding lives in the repo's own git config (shared by all its worktrees, never committed,
|
|
123
|
+
// never pushed), under hivecode.<branch>.*: room = the join link, from = the branch a PR goes
|
|
124
|
+
// into, base = the commit the room was seeded from, seed = the developer's own room folder.
|
|
125
|
+
const KEY = (branch, k) => `hivecode.${branch}.${k}`
|
|
126
|
+
export function readBinding(root, branch) {
|
|
127
|
+
const get = (k) => { const r = git(root, ['config', '--local', '--get', KEY(branch, k)]); return r.code === 0 ? r.out : '' }
|
|
128
|
+
const link = get('room')
|
|
129
|
+
return link ? { link, from: get('from'), base: get('base'), seed: get('seed'), files: get('files') } : null
|
|
130
|
+
}
|
|
131
|
+
function writeBinding(root, branch, b) {
|
|
132
|
+
for (const [k, v] of Object.entries(b)) {
|
|
133
|
+
if (!v) continue
|
|
134
|
+
const r = git(root, ['config', '--local', KEY(branch, k), v])
|
|
135
|
+
if (r.code !== 0) throw coded('Q569', `could not record the room in git config: ${r.err}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Every regular file in `commit`, with its bytes. Symlinks and submodules are not files a room holds. */
|
|
140
|
+
export function treeFiles(root, commit) {
|
|
141
|
+
const ls = git(root, ['ls-tree', '-r', '-z', '--full-tree', commit], { trim: false })
|
|
142
|
+
if (ls.code !== 0) throw coded('Q563', `could not read the files of ${commit}: ${ls.err}`)
|
|
143
|
+
const entries = []
|
|
144
|
+
for (const rec of ls.out.split('\0')) {
|
|
145
|
+
const tab = rec.indexOf('\t')
|
|
146
|
+
if (tab < 0) continue
|
|
147
|
+
const [mode, type, sha] = rec.slice(0, tab).split(' ')
|
|
148
|
+
const rel = rec.slice(tab + 1)
|
|
149
|
+
if (type !== 'blob' || (mode !== '100644' && mode !== '100755') || !isSafeRelPath(rel)) continue
|
|
150
|
+
entries.push({ rel, mode, sha })
|
|
151
|
+
}
|
|
152
|
+
const blobs = readBlobs(root, [...new Set(entries.map((e) => e.sha))])
|
|
153
|
+
return entries.filter((e) => blobs.has(e.sha)).map((e) => ({ ...e, buf: blobs.get(e.sha) }))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `git cat-file --batch`, parsed: sha -> Buffer, exactly the bytes git stored (no smudge, no CRLF). */
|
|
157
|
+
function readBlobs(root, shas) {
|
|
158
|
+
const out = new Map()
|
|
159
|
+
if (!shas.length) return out
|
|
160
|
+
const r = git(root, ['cat-file', '--batch'], { input: shas.join('\n') + '\n', raw: true })
|
|
161
|
+
if (r.code !== 0) throw coded('Q563', `could not read file contents from git: ${r.err}`)
|
|
162
|
+
const buf = r.out
|
|
163
|
+
let i = 0
|
|
164
|
+
while (i < buf.length) {
|
|
165
|
+
const nl = buf.indexOf(10, i)
|
|
166
|
+
if (nl < 0) break
|
|
167
|
+
const [sha, type, size] = buf.subarray(i, nl).toString('utf8').split(' ')
|
|
168
|
+
if (type === 'missing' || size === undefined) { i = nl + 1; continue }
|
|
169
|
+
const n = Number(size)
|
|
170
|
+
out.set(sha, buf.subarray(nl + 1, nl + 1 + n))
|
|
171
|
+
i = nl + 1 + n + 1
|
|
172
|
+
}
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Would a sync client publish this file? The same tests the sync applies -- text, at most 1 MB,
|
|
178
|
+
* not always-ignored, not ignored by a .gitignore in the tree, not one of Hivecode's own files --
|
|
179
|
+
* so the seed can say exactly what the room will hold, and a joiner can be given the rest from git.
|
|
180
|
+
*/
|
|
181
|
+
export function roomCarries(files) {
|
|
182
|
+
const ig = ignore().add(ALWAYS_IGNORE)
|
|
183
|
+
const rootIgnore = files.find((f) => f.rel === '.gitignore')
|
|
184
|
+
if (rootIgnore) ig.add(rootIgnore.buf.toString('utf8'))
|
|
185
|
+
const nested = files.filter((f) => f.rel.endsWith('/.gitignore'))
|
|
186
|
+
.map((f) => ({ prefix: f.rel.slice(0, -'.gitignore'.length), ig: ignore().add(f.buf.toString('utf8')) }))
|
|
187
|
+
const ignored = (rel) => {
|
|
188
|
+
if (ig.ignores(rel)) return true
|
|
189
|
+
for (const n of nested) {
|
|
190
|
+
if (!rel.startsWith(n.prefix)) continue
|
|
191
|
+
const sub = rel.slice(n.prefix.length)
|
|
192
|
+
if (sub && n.ig.ignores(sub)) return true
|
|
193
|
+
}
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
// A file holding a key is held at home by every client (sync.js heldForSecret), so a room that
|
|
197
|
+
// promised to carry it would never finish seeding (measured 3 Oct 2026: Q565, "4 of 5 files",
|
|
198
|
+
// nothing bound). It is not carried, and each participant gets it from git like a binary.
|
|
199
|
+
const holdsKey = (f) => process.env.HIVE_SHARE_SECRETS !== '1' && findSecrets(f.buf.toString('utf8')).length > 0
|
|
200
|
+
return (f) => f.buf.length <= TEXT_MAX && !f.buf.includes(0) && !SKIP.has(f.rel) && !ignored(f.rel) && !holdsKey(f)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Hivecode's own bookkeeping (.hive.json, .hive-shadow.json, .hive-lock.json, ...). If a repo happens
|
|
204
|
+
// to track one, it is never written into a room folder: a foreign .hive-lock.json would make the
|
|
205
|
+
// folder look held by some other process, and a foreign shadow is somebody else's ancestry.
|
|
206
|
+
// By exact name: a project's own `.hiverc` is its business. All of these are already never carried
|
|
207
|
+
// (always-ignored, or rendered locally), so the seed and a fresh joiner agree on what the room holds.
|
|
208
|
+
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'])
|
|
209
|
+
const isOwnFile = (rel) => OWN_FILES.has(path.posix.basename(rel))
|
|
210
|
+
|
|
211
|
+
function writeFiles(dir, files) {
|
|
212
|
+
for (const f of files) {
|
|
213
|
+
if (isOwnFile(f.rel)) continue
|
|
214
|
+
const full = path.join(dir, ...f.rel.split('/'))
|
|
215
|
+
fs.mkdirSync(path.dirname(full), { recursive: true })
|
|
216
|
+
fs.writeFileSync(full, f.buf)
|
|
217
|
+
if (f.mode === '100755' && process.platform !== 'win32') { try { fs.chmodSync(full, 0o755) } catch { /* best effort */ } }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- rule 2: a room folder is never a git checkout ------------------------------------------
|
|
222
|
+
|
|
223
|
+
// One line, because git reads a `.git` file's first line as the path to the repository. The path
|
|
224
|
+
// does not exist, so every git command run in the folder stops with "not a git repository" and the
|
|
225
|
+
// path it prints says why. An EMPTY `.git` directory would not do this: git skips an invalid one
|
|
226
|
+
// and walks on up to the nearest real repository (measured: `git stash -u` run inside the folder
|
|
227
|
+
// then stashed the room's files away through the parent repo, which deletes them).
|
|
228
|
+
export const NOT_A_CHECKOUT = 'gitdir: ./.hivecode-live-room-not-a-git-checkout\n'
|
|
229
|
+
|
|
230
|
+
export function markNotACheckout(dir) {
|
|
231
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
232
|
+
const p = path.join(dir, '.git')
|
|
233
|
+
let st = null
|
|
234
|
+
try { st = fs.lstatSync(p) } catch { /* absent: ours to create */ }
|
|
235
|
+
if (st) {
|
|
236
|
+
if (st.isFile() && fs.readFileSync(p, 'utf8') === NOT_A_CHECKOUT) return { ok: true }
|
|
237
|
+
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.` }
|
|
238
|
+
}
|
|
239
|
+
fs.writeFileSync(p, NOT_A_CHECKOUT)
|
|
240
|
+
return { ok: true }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The files the room cannot carry (binary, over 1 MB, always-ignored), written into a joiner's
|
|
245
|
+
* folder from the branch. ONLY those: a file the room does carry but that is missing here was
|
|
246
|
+
* deleted in the room, and writing it back would publish the deletion's undo to everyone.
|
|
247
|
+
*/
|
|
248
|
+
export function fillFromGit({ root, commit, dir }) {
|
|
249
|
+
const files = treeFiles(root, commit)
|
|
250
|
+
const carries = roomCarries(files)
|
|
251
|
+
const missing = files.filter((f) => !carries(f) && !SKIP.has(f.rel) && !isOwnFile(f.rel) && !fs.existsSync(path.join(dir, ...f.rel.split('/'))))
|
|
252
|
+
writeFiles(dir, missing)
|
|
253
|
+
return missing.length
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* What hive_join needs for { branch }: the room link, and the commit to fill non-text files from.
|
|
258
|
+
* Returns { ok:false, message } with the next step instead of throwing -- an agent acts on text.
|
|
259
|
+
*/
|
|
260
|
+
export function resolveForJoin({ branch, repo }) {
|
|
261
|
+
let root
|
|
262
|
+
try { root = projectRoot(repo) } catch (e) {
|
|
263
|
+
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.` }
|
|
264
|
+
}
|
|
265
|
+
let name
|
|
266
|
+
try { name = validBranch(root, branch) } catch (e) { return { ok: false, message: e.message } }
|
|
267
|
+
const b = readBinding(root, name)
|
|
268
|
+
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>"` }
|
|
269
|
+
// A teammate's clone usually has the branch only as origin/<name>; that is as good a source for
|
|
270
|
+
// the files the room does not carry. With neither, those files are simply not filled.
|
|
271
|
+
const commit = revParse(root, `refs/heads/${name}`) || revParse(root, `refs/remotes/origin/${name}`) || b.base
|
|
272
|
+
return { ok: true, root, branch: name, link: b.link, commit }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- seeding: the room starts as the branch ------------------------------------------------
|
|
276
|
+
|
|
277
|
+
async function waitFor(pred, ms, every = 200) {
|
|
278
|
+
const t0 = Date.now()
|
|
279
|
+
for (;;) {
|
|
280
|
+
let v = false
|
|
281
|
+
try { v = pred() } catch { /* not ready */ }
|
|
282
|
+
if (v) return true
|
|
283
|
+
if (Date.now() - t0 > ms) return false
|
|
284
|
+
await sleep(every)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Publish `commit` into the (empty) room from the developer's own room folder, and report success
|
|
290
|
+
* only after a FRESH JOINER has received every file the room should hold, byte for byte. A seed
|
|
291
|
+
* that is merely "sent" is the false success this refuses: agents would build on a partial tree.
|
|
292
|
+
*/
|
|
293
|
+
async function seedRoom({ root, branch, commit, link, you, log }) {
|
|
294
|
+
const { relay, room } = parseLink(link)
|
|
295
|
+
const files = treeFiles(root, commit)
|
|
296
|
+
const carries = roomCarries(files)
|
|
297
|
+
const expected = new Map(files.filter(carries).map((f) => [f.rel, f.buf.toString('utf8')]))
|
|
298
|
+
const dir = roomFolder(link, you)
|
|
299
|
+
const mark = markNotACheckout(dir)
|
|
300
|
+
if (!mark.ok) throw coded('Q564', mark.why)
|
|
301
|
+
writeFiles(dir, files)
|
|
302
|
+
fs.writeFileSync(path.join(dir, '.hive.json'), JSON.stringify({ relay, room }, null, 2))
|
|
303
|
+
|
|
304
|
+
// PROVENANCE OFF FOR THE SEED. The seed is the branch exactly as it already is; a receipt for it
|
|
305
|
+
// would name the developer as a co-author of every file an agent later changes. Agents' first
|
|
306
|
+
// receipts still chain from it, because a receipt's `parent` is the hash of what it built on.
|
|
307
|
+
const prov = process.env.HIVE_PROVENANCE
|
|
308
|
+
process.env.HIVE_PROVENANCE = 'off'
|
|
309
|
+
let seeder
|
|
310
|
+
try { seeder = startSync({ relay, room, dir, name: you, kind: 'human', log: () => {} }) }
|
|
311
|
+
finally { if (prov === undefined) delete process.env.HIVE_PROVENANCE; else process.env.HIVE_PROVENANCE = prov }
|
|
312
|
+
|
|
313
|
+
const budget = 30000 + 40 * expected.size
|
|
314
|
+
let verifier = null
|
|
315
|
+
const vdir = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-seed-check-'))
|
|
316
|
+
try {
|
|
317
|
+
const up = await waitFor(() => seeder.connection().connected && [...expected].every(([r, t]) => seeder._fileText(r) === t), budget)
|
|
318
|
+
if (!up) {
|
|
319
|
+
const st = seeder.status()
|
|
320
|
+
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.`)
|
|
321
|
+
}
|
|
322
|
+
verifier = startSync({ relay, room, dir: vdir, name: `${you}-check`, kind: 'ai', passive: true, log: () => {} })
|
|
323
|
+
const read = (r) => { try { return fs.readFileSync(path.join(vdir, ...r.split('/')), 'utf8') } catch { return null } }
|
|
324
|
+
const got = await waitFor(() => [...expected].every(([r, t]) => read(r) === t), budget, 300)
|
|
325
|
+
if (!got) {
|
|
326
|
+
const missing = [...expected.keys()].filter((r) => read(r) !== expected.get(r))
|
|
327
|
+
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.`)
|
|
328
|
+
}
|
|
329
|
+
const short = commit.slice(0, 7)
|
|
330
|
+
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.`)
|
|
331
|
+
await sleep(600) // let the announcement leave before the socket closes
|
|
332
|
+
return { dir, count: expected.size, total: files.length }
|
|
333
|
+
} finally {
|
|
334
|
+
try { verifier && verifier.stop() } catch { /* going away */ }
|
|
335
|
+
try { seeder.stop() } catch { /* going away */ }
|
|
336
|
+
try { fs.rmSync(vdir, { recursive: true, force: true }) } catch { /* temp */ }
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The paths the room lists right now, from a passive look that syncs no files (null if unreachable). */
|
|
341
|
+
async function roomPaths(link, you) {
|
|
342
|
+
const { relay, room } = parseLink(link)
|
|
343
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-peek-'))
|
|
344
|
+
const peek = startSync({ relay, room, dir, name: `${you}-peek`, kind: 'ai', passive: true, syncFiles: false, log: () => {} })
|
|
345
|
+
try {
|
|
346
|
+
let synced = false
|
|
347
|
+
peek.provider.on('sync', (s) => { if (s) synced = true })
|
|
348
|
+
if (!(await waitFor(() => synced, 15000))) return null
|
|
349
|
+
await sleep(300)
|
|
350
|
+
return new Set(peek.doc.getMap('manifest').keys())
|
|
351
|
+
} finally {
|
|
352
|
+
try { peek.stop() } catch { }
|
|
353
|
+
try { fs.rmSync(dir, { recursive: true, force: true }) } catch { }
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const roomFileCount = async (link, you) => { const p = await roomPaths(link, you); return p ? p.size : null }
|
|
357
|
+
|
|
358
|
+
/** The branch a PR from `name` goes into: the checkout's current branch, else origin's default. */
|
|
359
|
+
function defaultBase(root, name) {
|
|
360
|
+
const cur = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
|
|
361
|
+
if (cur.code === 0 && cur.out && cur.out !== name) return cur.out
|
|
362
|
+
const o = git(root, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
|
|
363
|
+
const d = o.code === 0 && o.out ? o.out.replace(/^origin\//, '') : ''
|
|
364
|
+
return d !== name ? d : ''
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Delete a room folder ONLY if it is one this tool made: under HIVE_HOME, carrying our `.git`
|
|
369
|
+
* line, and naming this room in its .hive.json. The path comes from git config, and config is
|
|
370
|
+
* editable text -- a path that fails any of these is refused, never removed.
|
|
371
|
+
*/
|
|
372
|
+
export function removeRoomFolder(dir, room) {
|
|
373
|
+
const abs = path.resolve(dir)
|
|
374
|
+
const home = path.resolve(hiveHome()) + path.sep
|
|
375
|
+
let ours = false
|
|
376
|
+
try {
|
|
377
|
+
ours = abs.startsWith(home) &&
|
|
378
|
+
fs.readFileSync(path.join(abs, '.git'), 'utf8') === NOT_A_CHECKOUT &&
|
|
379
|
+
JSON.parse(fs.readFileSync(path.join(abs, '.hive.json'), 'utf8')).room === room
|
|
380
|
+
} catch { ours = false }
|
|
381
|
+
if (!fs.existsSync(abs)) return
|
|
382
|
+
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.`)
|
|
383
|
+
fs.rmSync(abs, { recursive: true, force: true })
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function youFrom(root, given) {
|
|
387
|
+
const raw = given || git(root, ['config', 'user.name']).out || os.userInfo().username || 'dev'
|
|
388
|
+
return validateMemberName(raw).ok ? raw : suggestMemberName(raw)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* `branch <name>`: bind a room to a branch, creating and seeding both on first use.
|
|
393
|
+
* The developer's checkout is never switched or written: the branch is made with `git branch`,
|
|
394
|
+
* and the seed is read out of git objects, not out of the working tree.
|
|
395
|
+
*/
|
|
396
|
+
export async function bindBranch({ cwd, branch, from = '', relay = '', you = '', join = '', reseed = false, log = () => {} }) {
|
|
397
|
+
const root = projectRoot(cwd)
|
|
398
|
+
const name = validBranch(root, branch)
|
|
399
|
+
const me = youFrom(root, you)
|
|
400
|
+
const existing = readBinding(root, name)
|
|
401
|
+
|
|
402
|
+
if (join) {
|
|
403
|
+
const { relay: jr, room } = parseLink(join)
|
|
404
|
+
if (!jr || !room) throw coded('Q567', `"${join}" is not a room link (it looks like wss://relay|room-...).`)
|
|
405
|
+
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}"`)
|
|
406
|
+
writeBinding(root, name, { room: join })
|
|
407
|
+
return { root, branch: name, link: join, you: me, joined: true }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (existing && !reseed) {
|
|
411
|
+
const n = await roomFileCount(existing.link, me)
|
|
412
|
+
// Empty is only news when the seed was not: a branch with nothing a room can carry seeds nothing.
|
|
413
|
+
const lost = n === 0 && Number(existing.files || 1) > 0
|
|
414
|
+
return { root, branch: name, link: existing.link, you: me, from: existing.from, existing: true, roomFiles: n, lost }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const relayUrl = currentRelayFor(relay || process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY).relay
|
|
418
|
+
let created = false
|
|
419
|
+
let tip = revParse(root, `refs/heads/${name}`)
|
|
420
|
+
// The PR's base is a BRANCH NAME for `gh`: a sha names no branch, and origin/main means main.
|
|
421
|
+
let fromName = from && !/^[0-9a-f]{7,40}$/i.test(from) ? from.replace(/^origin\//, '') : ''
|
|
422
|
+
if (!tip) {
|
|
423
|
+
const src = from || 'HEAD'
|
|
424
|
+
const sha = revParse(root, `${src}^{commit}`)
|
|
425
|
+
if (!sha) {
|
|
426
|
+
if (src === 'HEAD' && !revParse(root, 'HEAD')) {
|
|
427
|
+
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.`)
|
|
428
|
+
}
|
|
429
|
+
throw coded('Q562', `"${src}" is not a commit in ${root}.`)
|
|
430
|
+
}
|
|
431
|
+
const r = git(root, ['branch', '--no-track', name, sha]) // `branch`, never `checkout -b`: the checkout stays where it is
|
|
432
|
+
if (r.code !== 0) throw coded('Q562', `could not create branch ${name}: ${r.err}`)
|
|
433
|
+
tip = sha
|
|
434
|
+
created = true
|
|
435
|
+
}
|
|
436
|
+
if (!fromName) fromName = defaultBase(root, name)
|
|
437
|
+
|
|
438
|
+
let link = existing && existing.link
|
|
439
|
+
if (reseed) {
|
|
440
|
+
if (!existing) throw coded('Q562', `branch "${name}" has no room to reseed; run without --reseed to create one.`)
|
|
441
|
+
const n = await roomFileCount(existing.link, me)
|
|
442
|
+
if (n === null) throw coded('Q565', `could not reach the relay behind ${existing.link}.`)
|
|
443
|
+
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.`)
|
|
444
|
+
// A fresh seed folder: the old one's record of what it last wrote belongs to the lost room.
|
|
445
|
+
removeRoomFolder(existing.seed || roomFolder(existing.link, me), parseLink(existing.link).room)
|
|
446
|
+
if (existing.seed && path.resolve(existing.seed) !== path.resolve(roomFolder(existing.link, me))) removeRoomFolder(roomFolder(existing.link, me), parseLink(existing.link).room)
|
|
447
|
+
} else {
|
|
448
|
+
link = `${relayUrl}|room-${crypto.randomBytes(13).toString('base64url')}`
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
let seeded
|
|
452
|
+
try {
|
|
453
|
+
seeded = await seedRoom({ root, branch: name, commit: tip, link, you: me, log })
|
|
454
|
+
} catch (e) {
|
|
455
|
+
// A refused bind leaves nothing behind: not a binding, not a branch nobody asked to keep, and
|
|
456
|
+
// not a half-seeded folder for the next attempt (which gets a new room) to trip over.
|
|
457
|
+
if (created && revParse(root, `refs/heads/${name}`) === tip) git(root, ['branch', '-D', name])
|
|
458
|
+
try { removeRoomFolder(roomFolder(link, me), parseLink(link).room) } catch { /* not ours to remove */ }
|
|
459
|
+
throw e
|
|
460
|
+
}
|
|
461
|
+
writeBinding(root, name, { room: link, from: fromName, base: tip, seed: seeded.dir, files: String(seeded.count) })
|
|
462
|
+
return { root, branch: name, link, you: me, from: fromName, commit: tip, created, reseeded: reseed, files: seeded.count, total: seeded.total, seedDir: seeded.dir }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---- landing: the room's work becomes commits on the branch ---------------------------------
|
|
466
|
+
|
|
467
|
+
const HIVE_PR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'hive-pr.js')
|
|
468
|
+
const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* THE ROOM'S DELETIONS, which hive-pr does not land: it copies the files a room holds, so a file an
|
|
472
|
+
* agent deleted would simply stay on the branch, in a PR that looks complete. A room bound to a
|
|
473
|
+
* branch starts as the whole branch, so "on the branch but no longer in the room" is a deletion --
|
|
474
|
+
* but only when the room provably held THAT version of the file. For each such path:
|
|
475
|
+
* - last changed on the branch by a landing from this room (its commit says "Room: <id>") -> delete
|
|
476
|
+
* - unchanged since the seed commit -> delete
|
|
477
|
+
* - in the seed, but changed on the branch since by someone else -> REFUSE (Q572):
|
|
478
|
+
* deleting it would erase a change the room never saw
|
|
479
|
+
* - not in the seed and not landed from this room: added to the branch outside the room -> keep
|
|
480
|
+
*/
|
|
481
|
+
async function roomDeletions({ root, link, base, tip }) {
|
|
482
|
+
if (!base) return []
|
|
483
|
+
const paths = await roomPaths(link, 'hive-land')
|
|
484
|
+
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.`)
|
|
485
|
+
const room = parseLink(link).room
|
|
486
|
+
const atTip = treeFiles(root, tip)
|
|
487
|
+
const carries = roomCarries(atTip)
|
|
488
|
+
const baseSha = new Map(treeFiles(root, base).map((f) => [f.rel, f.sha]))
|
|
489
|
+
const gone = [], diverged = []
|
|
490
|
+
for (const f of atTip) {
|
|
491
|
+
if (!carries(f) || paths.has(f.rel)) continue
|
|
492
|
+
const last = git(root, ['log', '-1', '--format=%B', tip, '--', f.rel]).out
|
|
493
|
+
if (new RegExp(`^Room: ${reEsc(room)}$`, 'm').test(last)) gone.push(f.rel)
|
|
494
|
+
else if (baseSha.get(f.rel) === f.sha) gone.push(f.rel)
|
|
495
|
+
else if (baseSha.has(f.rel)) diverged.push(f.rel)
|
|
496
|
+
}
|
|
497
|
+
if (diverged.length) {
|
|
498
|
+
const who = (rel) => git(root, ['log', '-1', '--format=%h %an %ad %s', '--date=short', tip, '--', rel]).out
|
|
499
|
+
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.`)
|
|
500
|
+
}
|
|
501
|
+
return gone
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function worktreesOn(root, branch) {
|
|
505
|
+
const r = git(root, ['worktree', 'list', '--porcelain'])
|
|
506
|
+
const out = []
|
|
507
|
+
let cur = null
|
|
508
|
+
for (const line of r.out.split('\n')) {
|
|
509
|
+
if (line.startsWith('worktree ')) cur = line.slice(9)
|
|
510
|
+
else if (line === `branch refs/heads/${branch}` && cur) out.push(path.resolve(cur))
|
|
511
|
+
}
|
|
512
|
+
return out
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* `pr <name>`: land the room's work on the bound branch as one commit per agent.
|
|
517
|
+
*
|
|
518
|
+
* hive-pr writes the commits (attribution and the staleness check are all its own), but it runs in
|
|
519
|
+
* a TEMPORARY WORKTREE detached at the branch tip, because hive-pr switches the checkout it is given
|
|
520
|
+
* and the developer's must never be switched. The branch then moves by compare-and-swap from the tip
|
|
521
|
+
* the worktree was cut from, so a commit that reached the branch meanwhile is refused, never lost.
|
|
522
|
+
*/
|
|
523
|
+
export async function landBranch({ cwd, branch, dry = false, push = false, pr = false, base = '', force = '', only = '', title = '', log = console.log, logErr = console.error }) {
|
|
524
|
+
const root = projectRoot(cwd)
|
|
525
|
+
const name = validBranch(root, branch)
|
|
526
|
+
const b = readBinding(root, name)
|
|
527
|
+
if (!b) throw coded('Q562', `branch "${name}" has no Hivecode room here. Create one with: npx -y hivecode-mcp@latest branch ${name}`)
|
|
528
|
+
const tip = revParse(root, `refs/heads/${name}`)
|
|
529
|
+
if (!tip) throw coded('Q563', `branch "${name}" does not exist in ${root}. Fetch it first: git fetch origin ${name}:${name}`)
|
|
530
|
+
|
|
531
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-land-'))
|
|
532
|
+
const temp = `hivecode-land/${safeSegment(name)}-${process.pid}-${Date.now()}`
|
|
533
|
+
const cleanup = () => {
|
|
534
|
+
git(root, ['worktree', 'remove', '--force', tmp])
|
|
535
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }) } catch { }
|
|
536
|
+
git(root, ['worktree', 'prune'])
|
|
537
|
+
if (revParse(root, `refs/heads/${temp}`)) git(root, ['branch', '-D', temp])
|
|
538
|
+
}
|
|
539
|
+
const wt = git(root, noHooks(['worktree', 'add', '--detach', tmp, tip]))
|
|
540
|
+
if (wt.code !== 0) { cleanup(); throw coded('Q563', `could not make a temporary worktree: ${wt.err}`) }
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
// Accounted for BEFORE anything is written, so a refusal (Q572) leaves the branch as it was.
|
|
544
|
+
const deletions = await roomDeletions({ root, link: b.link, base: b.base, tip })
|
|
545
|
+
|
|
546
|
+
const args = [HIVE_PR, b.link, 'hive-pr', '--repo', tmp, '--branch', temp, '--base', 'HEAD']
|
|
547
|
+
if (dry) args.push('--dry-run')
|
|
548
|
+
if (force) args.push('--force', force)
|
|
549
|
+
if (only) args.push('--only', only)
|
|
550
|
+
const r = spawnSync(process.execPath, args, { cwd: tmp, encoding: 'utf8', env: noHooksEnv({ ...process.env, HIVE_PROVENANCE: 'on' }), maxBuffer: 64 * 1024 * 1024, windowsHide: true })
|
|
551
|
+
if (r.stdout) log(r.stdout.replace(/\s+$/, ''))
|
|
552
|
+
// Q501 is hive-pr saying no file in the room carries an agent's receipt: nothing was changed or
|
|
553
|
+
// added. For a branch room that is an answer, not a failure -- and deletions may still land.
|
|
554
|
+
const nothingChanged = r.status !== 0 && /^Q501 /m.test(r.stderr || '')
|
|
555
|
+
if (r.stderr && !nothingChanged) logErr(r.stderr.replace(/\s+$/, ''))
|
|
556
|
+
if (r.status !== 0 && !nothingChanged) return { ok: false, code: r.status || 1 }
|
|
557
|
+
if (dry) {
|
|
558
|
+
if (deletions.length) log(`would delete ${deletions.length} file(s) the room deleted: ${deletions.join(', ')}`)
|
|
559
|
+
return { ok: true, dry: true, deletions }
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (deletions.length) {
|
|
563
|
+
// hive-pr left the temporary worktree on its branch when it committed; when it had nothing to
|
|
564
|
+
// commit it never made one, so make it here, at the same tip.
|
|
565
|
+
if (!revParse(root, `refs/heads/${temp}`)) git(tmp, noHooks(['checkout', '-q', '-b', temp]))
|
|
566
|
+
const rm = git(tmp, ['rm', '-q', '--', ...deletions])
|
|
567
|
+
if (rm.code !== 0) throw coded('Q563', `could not stage the room's deletions: ${rm.err}`)
|
|
568
|
+
const msg = [`Room: delete ${deletions.length === 1 ? deletions[0] : `${deletions.length} files`}`, '', ...deletions.map((d) => ` - ${d}`), '', `Room: ${parseLink(b.link).room}`].join('\n')
|
|
569
|
+
const c = git(tmp, noHooks(['commit', '-q', '-m', msg, '--author', 'hivecode-room <room@agents.hivecode.local>']))
|
|
570
|
+
if (c.code !== 0) throw coded('Q563', `could not commit the room's deletions: ${c.err || c.out}`)
|
|
571
|
+
}
|
|
572
|
+
// NOTHING NEW TO LAND IS NOT "DONE" WHEN A PUSH OR A PR WAS ASKED FOR. The usual sequence is a
|
|
573
|
+
// plain `pr` to look, then `pr --pr`; the second run lands nothing, and returning here skipped
|
|
574
|
+
// the push and the PR while printing success (1 Nov 2026). Push and open what is already there.
|
|
575
|
+
let nothingNew = false
|
|
576
|
+
if (nothingChanged && !deletions.length) { log(`nothing to land yet: no agent has changed a file in the room for ${name}.`); nothingNew = true }
|
|
577
|
+
const newTip = nothingNew ? tip : revParse(root, `refs/heads/${temp}`)
|
|
578
|
+
if (!nothingNew && (!newTip || newTip === tip)) { log(`nothing to land: ${name} already has everything the room holds.`); nothingNew = true }
|
|
579
|
+
if (nothingNew && !push && !pr) return { ok: true, landed: 0 }
|
|
580
|
+
if (nothingNew) return finish('')
|
|
581
|
+
|
|
582
|
+
// Move the branch. Checked out somewhere: only a clean fast-forward of that checkout, which is
|
|
583
|
+
// what `git pull` would do; with local changes it is refused, because moving the branch under a
|
|
584
|
+
// dirty checkout makes its edits look like reversals of the landed work.
|
|
585
|
+
const on = worktreesOn(root, name).filter((p) => p !== path.resolve(tmp))
|
|
586
|
+
if (on.length) {
|
|
587
|
+
const where = on[0]
|
|
588
|
+
const dirty = git(where, ['status', '--porcelain', '--untracked-files=no'])
|
|
589
|
+
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.`)
|
|
590
|
+
const ff = git(where, ['merge', '--ff-only', '--quiet', newTip])
|
|
591
|
+
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.`)
|
|
592
|
+
} else {
|
|
593
|
+
const cas = git(root, ['update-ref', '-m', 'hivecode: land room work', `refs/heads/${name}`, newTip, tip])
|
|
594
|
+
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.`)
|
|
595
|
+
}
|
|
596
|
+
const made = git(root, ['log', '--format= %h %an %s', `${tip}..${newTip}`]).out
|
|
597
|
+
log(`\nlanded on ${name}:\n${made}`)
|
|
598
|
+
return finish(made)
|
|
599
|
+
|
|
600
|
+
// `made` is '' when nothing new landed this run: the push and the PR are of what is already on
|
|
601
|
+
// the branch, and the PR body lists the branch's commits since its base instead.
|
|
602
|
+
function finish(made) {
|
|
603
|
+
if (push) {
|
|
604
|
+
const p = git(root, ['push', '-u', 'origin', name])
|
|
605
|
+
if (p.code !== 0) throw coded('Q573', `the work ${made ? 'landed' : 'is'} on ${name} locally, but the push failed: ${p.err}`)
|
|
606
|
+
log(`pushed ${name}`)
|
|
607
|
+
}
|
|
608
|
+
if (pr) {
|
|
609
|
+
const into = base || b.from
|
|
610
|
+
if (!into) throw coded('Q574', `the work is on ${name}, but there is no branch to open the PR into; pass --base <branch>.`)
|
|
611
|
+
const listed = made || git(root, ['log', '--format= %h %an %s', `${into}..${name}`]).out
|
|
612
|
+
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.', '', '```', listed, '```'].join('\n')
|
|
613
|
+
const g = spawnSync('gh', ['pr', 'create', '--base', into, '--head', name, '--title', title || `Hivecode: ${name}`, '--body', body], { cwd: root, encoding: 'utf8', windowsHide: true })
|
|
614
|
+
if (g.status !== 0) throw coded('Q574', `the work is on ${name}, but gh pr create failed: ${(g.stderr || (g.error && g.error.message) || '').toString().trim()}`)
|
|
615
|
+
log((g.stdout || '').trim())
|
|
616
|
+
}
|
|
617
|
+
return { ok: true, landed: made ? made.split('\n').filter(Boolean).length : 0, tip: made ? newTip : tip, deletions }
|
|
618
|
+
}
|
|
619
|
+
} finally {
|
|
620
|
+
cleanup()
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ---- CLI ----------------------------------------------------------------------------------
|
|
625
|
+
|
|
626
|
+
function flags(argv) {
|
|
627
|
+
const pos = []
|
|
628
|
+
const f = {}
|
|
629
|
+
for (let i = 0; i < argv.length; i++) {
|
|
630
|
+
const a = argv[i]
|
|
631
|
+
if (!a.startsWith('--')) { pos.push(a); continue }
|
|
632
|
+
const k = a.slice(2)
|
|
633
|
+
if (['dry-run', 'push', 'pr', 'reseed'].includes(k)) f[k] = true
|
|
634
|
+
else f[k] = argv[++i] || ''
|
|
635
|
+
}
|
|
636
|
+
return { pos, f }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const USAGE = `usage:
|
|
640
|
+
npx -y hivecode-mcp@latest branch <name> [--from <ref>] [--name <you>] [--relay <url>]
|
|
641
|
+
npx -y hivecode-mcp@latest branch <name> --join "<link>"
|
|
642
|
+
npx -y hivecode-mcp@latest branch <name> --reseed
|
|
643
|
+
npx -y hivecode-mcp@latest pr <name> [--dry-run] [--push] [--pr] [--base <branch>] [--force "<reason>"]`
|
|
644
|
+
|
|
645
|
+
/** `argv` starts at the subcommand: ['branch', 'fix-x', ...] or ['pr', 'fix-x', ...]. Returns an exit code. */
|
|
646
|
+
export async function main(argv, { cwd = process.cwd(), log = console.log, logErr = console.error } = {}) {
|
|
647
|
+
const [cmd, ...rest] = argv
|
|
648
|
+
const { pos, f } = flags(rest)
|
|
649
|
+
const name = pos[0]
|
|
650
|
+
if (!name || (cmd !== 'branch' && cmd !== 'pr')) { logErr(USAGE); return 2 }
|
|
651
|
+
try {
|
|
652
|
+
if (cmd === 'pr') {
|
|
653
|
+
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 })
|
|
654
|
+
return r.ok ? 0 : r.code || 1
|
|
655
|
+
}
|
|
656
|
+
const r = await bindBranch({ cwd, branch: name, from: f.from || '', relay: f.relay || '', you: f.name || '', join: f.join || '', reseed: !!f.reseed })
|
|
657
|
+
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)`
|
|
658
|
+
if (r.joined) {
|
|
659
|
+
log(`Recorded: branch ${r.branch} in ${r.root} is the room ${r.link}\n\nTell each of your agents:\n ${agentLine}`)
|
|
660
|
+
return 0
|
|
661
|
+
}
|
|
662
|
+
if (r.existing) {
|
|
663
|
+
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)`}`)
|
|
664
|
+
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`)
|
|
665
|
+
} else {
|
|
666
|
+
const dirty = git(r.root, ['status', '--porcelain'])
|
|
667
|
+
const dirtyLines = dirty.out ? dirty.out.split('\n').filter(Boolean) : []
|
|
668
|
+
const dirtyFiles = dirtyLines.map((l) => {
|
|
669
|
+
const p = l.slice(3).trim()
|
|
670
|
+
return p.includes(' -> ') ? p.split(' -> ')[1].trim() : p
|
|
671
|
+
}).filter(Boolean)
|
|
672
|
+
const shown = dirtyFiles.slice(0, 5).join(', ')
|
|
673
|
+
const more = dirtyFiles.length > 5 ? ` (+${dirtyFiles.length - 5} more)` : ''
|
|
674
|
+
const uncommittedPart = dirtyFiles.length ? `\n ${dirtyFiles.length} file(s) with uncommitted changes are not in the room: ${shown}${more}` : ''
|
|
675
|
+
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}`)
|
|
676
|
+
if (/onrender\.com/.test(r.link)) log(' This is the public relay: anyone who has this link can read these files.')
|
|
677
|
+
const roomName = parseLink(r.link).room
|
|
678
|
+
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.`)
|
|
679
|
+
}
|
|
680
|
+
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`)
|
|
681
|
+
return 0
|
|
682
|
+
} catch (e) {
|
|
683
|
+
logErr(e && e.message ? e.message : String(e))
|
|
684
|
+
return 1
|
|
685
|
+
}
|
|
686
|
+
}
|