hivecode-mcp 0.12.2 → 0.12.4

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-pr.js ADDED
@@ -0,0 +1,337 @@
1
+ // hive-pr — turn a room's landed work into a pull request, with per-agent attribution intact.
2
+ //
3
+ // node hive-pr.js "<relay|room>" <YourName> --repo <path> [options]
4
+ //
5
+ // --repo <path> the git working copy the PR is opened against (required)
6
+ // --branch <name> branch to create (default: hivecode/<room>-<stamp>)
7
+ // --base <name> branch to cut from (default: the repo's current HEAD)
8
+ // --title <text> PR title (default: derived from the room)
9
+ // --only <a,b,c> only these files
10
+ // --dry-run print the plan, touch nothing
11
+ // --push push the branch to origin
12
+ // --pr push and open a PR with `gh` (implies --push)
13
+ //
14
+ // WHY THIS EXISTS. A room is where the work happens; a pull request is where a team already
15
+ // trusts work to arrive. Asking a team to abandon pull requests is an enormous ask -- they are
16
+ // how review, compliance and audit happen, and in many companies they are mandatory for reasons
17
+ // nobody on the team controls. So we do not ask. The room's OUTPUT is a PR, and everything about
18
+ // the team's existing pipeline (CI, required reviewers, protected branches, deploy) keeps working
19
+ // exactly as it did.
20
+ //
21
+ // THE PART THAT IS NOT PLUMBING. Every change in a room is signed per participant, so we know
22
+ // which agent landed which file -- and that survives into git as ONE COMMIT PER AGENT, each with
23
+ // its real author and the full signed author id in a trailer. A reviewer opening the PR sees, in
24
+ // `git log`, exactly which agent wrote what. No orchestration framework can produce that: they
25
+ // run every agent inside one process under one identity, so the attribution never existed to
26
+ // carry across.
27
+ //
28
+ // Attribution is per FILE, not per line. The ledger records who landed each version of a file, so
29
+ // a file is credited to whoever landed its FINAL state, and everyone else who touched it rides
30
+ // along as a Co-authored-by trailer. Claiming line-level authorship would be a stronger statement
31
+ // than the data supports.
32
+
33
+ import fs from 'fs'
34
+ import os from 'os'
35
+ import path from 'path'
36
+ import { spawnSync } from 'child_process'
37
+ import { fileURLToPath } from 'url'
38
+ import { startSync, parseLink } from './sync.js'
39
+ import { accountFor, verdict, explain, DIVERGED } from './hive-pr-staleness.js'
40
+
41
+ const argv = process.argv.slice(2)
42
+ const [LINK = '', NAME = 'hive-pr'] = argv.filter((a) => !a.startsWith('--'))
43
+ const flag = (k, d = '') => { const i = argv.indexOf('--' + k); return i === -1 ? d : (argv[i + 1] || '') }
44
+ const has = (k) => argv.includes('--' + k)
45
+ // --force takes a REASON, not a bare switch. A switch is typed once and never explained; a
46
+ // required reason has to be written down, ends up in the PR body, and is the thing somebody asks
47
+ // about later. hive_release already works this way for exactly the same argument.
48
+ const FORCE = flag('force')
49
+
50
+ const REPO = flag('repo')
51
+ const DRY = has('dry-run')
52
+ const DO_PUSH = has('push') || has('pr')
53
+ const DO_PR = has('pr')
54
+ const ONLY = flag('only') ? flag('only').split(',').map((s) => s.trim()).filter(Boolean) : null
55
+
56
+ function die(code, msg) { console.error(`${code} ${msg}`); process.exit(1) }
57
+
58
+ /** Run git in the target repo. Never throws on a non-zero exit -- the caller decides. */
59
+ function git(args, opts = {}) {
60
+ const r = spawnSync('git', args, { cwd: REPO, encoding: 'utf8', ...opts })
61
+ return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() }
62
+ }
63
+
64
+ /** The same, WITHOUT trimming. For reading file CONTENT, where a trailing newline is part of the
65
+ * file and not whitespace around an answer.
66
+ *
67
+ * `git()` above trims, which is right for `rev-parse` and `log` and wrong for `cat-file blob` --
68
+ * and wrong in the worst way: it strips the final newline, so the content hash of a healthy file
69
+ * never matches the room's chain, every file reads DIVERGED, and the staleness check refuses
70
+ * every run. Caught by the end-to-end suite on its FIRST assertion; the unit suite could not
71
+ * have seen it, because it passes text straight in and never goes near git. */
72
+ function gitRaw(args, opts = {}) {
73
+ const r = spawnSync('git', args, { cwd: REPO, encoding: 'utf8', ...opts })
74
+ return { code: r.status, out: r.stdout == null ? '' : r.stdout, err: (r.stderr || '').trim() }
75
+ }
76
+
77
+ /** The authors of a file, newest-landed first, from VERIFIED receipts only.
78
+ * An unverifiable receipt is not evidence of authorship, so it is dropped rather than trusted. */
79
+ export function authorsOf(receipts, verify) {
80
+ const ok = receipts.filter((r) => r && r.author && (!verify || verify(r)))
81
+ if (!ok.length) return { last: null, contributors: [], first: 0 }
82
+ const last = ok[ok.length - 1]
83
+ const seen = new Map()
84
+ for (const r of ok) if (!seen.has(r.author)) seen.set(r.author, { author: r.author, name: r.name || 'agent', at: r.at || 0 })
85
+ return {
86
+ last: { author: last.author, name: last.name || 'agent', at: last.at || 0 },
87
+ contributors: [...seen.values()],
88
+ first: Math.min(...ok.map((r) => r.at || 0)),
89
+ }
90
+ }
91
+
92
+ const mailOf = (name) => `${String(name).toLowerCase().replace(/[^a-z0-9._-]+/g, '-') || 'agent'}@agents.hivecode.local`
93
+
94
+ /** Group files by the agent who landed their final state, oldest contribution first. */
95
+ export function planCommits(files) {
96
+ const groups = new Map()
97
+ for (const f of files) {
98
+ if (!f.last) continue
99
+ const g = groups.get(f.last.author) || { author: f.last.author, name: f.last.name, at: f.first, files: [], others: new Map() }
100
+ g.files.push(f)
101
+ g.at = Math.min(g.at, f.first)
102
+ for (const c of f.contributors) if (c.author !== f.last.author) g.others.set(c.author, c)
103
+ groups.set(f.last.author, g)
104
+ }
105
+ return [...groups.values()]
106
+ .map((g) => ({ ...g, others: [...g.others.values()] }))
107
+ .sort((a, b) => a.at - b.at || a.name.localeCompare(b.name))
108
+ }
109
+
110
+ export function commitMessage(g, room) {
111
+ const list = g.files.map((f) => f.rel).sort()
112
+ const head = list.length === 1 ? `${g.name}: ${list[0]}` : `${g.name}: ${list.length} files`
113
+ const lines = [head, '', ...list.map((x) => ` - ${x}`), '', `Room: ${room}`, `Hivecode-Author: ${g.author}`]
114
+ for (const o of g.others) lines.push(`Co-authored-by: ${o.name} <${mailOf(o.name)}>`)
115
+ return lines.join('\n')
116
+ }
117
+
118
+ // ---- main ---------------------------------------------------------------------------------
119
+ // COMPARED AS REAL PATHS, as hive-mcp.js does. The old test built `file:///` + argv[1], which is
120
+ // right only on Windows: on macOS and Linux argv[1] already starts with '/', the URL came out as
121
+ // `file:////...`, never matched, and the script exited 0 having done nothing at all.
122
+ const isMain = (() => {
123
+ if (!process.argv[1]) return false
124
+ const me = fileURLToPath(import.meta.url)
125
+ try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(me) } catch { return path.resolve(process.argv[1]) === path.resolve(me) }
126
+ })()
127
+ if (isMain) {
128
+ if (!LINK || !REPO) {
129
+ console.error('usage: node hive-pr.js "<relay|room>" <YourName> --repo <path> [--branch b] [--dry-run] [--push] [--pr] [--force "<reason>"]')
130
+ process.exit(2)
131
+ }
132
+ if (!fs.existsSync(path.join(REPO, '.git'))) die('Q500', `${REPO} is not a git repository`)
133
+
134
+ const { relay, room } = parseLink(LINK)
135
+ const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 13)
136
+ const BRANCH = flag('branch') || `hivecode/${room}-${stamp}`
137
+ const TITLE = flag('title') || `Hivecode: work from room ${room}`
138
+
139
+ // A scratch mirror of the room, so nothing here can disturb anyone's working folder.
140
+ // os.tmpdir(), not $TEMP: TEMP is a Windows variable, and without it the mirror was made in the
141
+ // current directory -- inside the very checkout the PR is cut from.
142
+ const dir = path.join(os.tmpdir(), `hive-pr-${room}-${process.pid}`)
143
+ fs.mkdirSync(dir, { recursive: true })
144
+
145
+ // Provenance is what this whole tool reads. Without it the ledger is empty and every
146
+ // commit would collapse to one anonymous blob -- which is exactly the thing we are here
147
+ // to avoid, so turn it on for this client rather than failing later with an empty plan.
148
+ process.env.HIVE_PROVENANCE = process.env.HIVE_PROVENANCE || 'on'
149
+
150
+ const hive = startSync({ relay, room, dir, name: NAME, kind: 'ai', token: process.env.HIVE_TOKEN || '', syncFiles: true, log: () => {} })
151
+
152
+ let done = false
153
+ // Cleared once the room answers. It used to stay armed for the whole run, so a landing that
154
+ // took longer than 20 s was killed as "could not reach the relay" -- mid-commit, possibly.
155
+ const unreachable = setTimeout(() => die('Q470', `could not reach the relay at ${relay}`), 20000)
156
+ hive.provider.on('sync', (s) => {
157
+ if (!s || done) return
158
+ done = true
159
+ clearTimeout(unreachable)
160
+ whenWholeRoomArrived()
161
+ })
162
+
163
+ // THE WHOLE ROOM, OR NOTHING. The file sub-docs arrive after the room doc, and this used to wait
164
+ // a flat 2.5 s for them. On a room bigger than what arrives in 2.5 s, the files still in flight
165
+ // were simply not in the mirror: not planned, not committed, not mentioned -- a PR that looks
166
+ // complete and silently leaves agents' work behind. Now the plan waits until every file the room
167
+ // lists is on disk here (a file's receipts travel in its own doc, so on disk means its ledger
168
+ // arrived too), keeps the 2.5 s floor, and refuses rather than land part of the room.
169
+ function whenWholeRoomArrived() {
170
+ const t0 = Date.now()
171
+ const limit = Number(process.env.HIVE_PR_WAIT_MS) || 120000
172
+ const tick = () => {
173
+ let st = null
174
+ try { st = hive.status() } catch { /* not ready yet */ }
175
+ if (st && st.filesMissing === 0 && Date.now() - t0 >= 2500) return run()
176
+ if (Date.now() - t0 > limit) {
177
+ const have = st ? `${st.filesHeld} of ${st.roomFiles}` : 'an unknown number'
178
+ console.error(`Q507 only ${have} of the room's files arrived in ${Math.round(limit / 1000)} s. Refusing to land part of the room: the missing files' work would be left out of the PR without anyone being told.`)
179
+ return finish(1)
180
+ }
181
+ setTimeout(tick, 250)
182
+ }
183
+ tick()
184
+ }
185
+
186
+ function walk(root, base = '') {
187
+ const out = []
188
+ for (const e of fs.readdirSync(path.join(root, base), { withFileTypes: true })) {
189
+ // Git's own and this client's bookkeeping (.hive.json, .hive-shadow.json, ...). Every other
190
+ // dotfile is project content: this skipped ALL of them, so an agent's edit to .gitignore or
191
+ // .github/workflows never reached the PR, and nothing said so. Only files with a verified
192
+ // receipt are committed either way, so bookkeeping that slipped through would still be left out.
193
+ if (e.name === '.git' || e.name.startsWith('.hive')) continue
194
+ const rel = base ? `${base}/${e.name}` : e.name
195
+ if (e.isDirectory()) out.push(...walk(root, rel))
196
+ else out.push(rel)
197
+ }
198
+ return out
199
+ }
200
+
201
+ function run() {
202
+ let rels = walk(dir)
203
+ if (ONLY) rels = rels.filter((r) => ONLY.includes(r))
204
+ const files = []
205
+ for (const rel of rels) {
206
+ const receipts = hive.provenanceOf(rel) || []
207
+ const audit = hive.verifyProvenanceOf(rel) || { bad: [] }
208
+ const badIdx = new Set((audit.bad || []).map((b) => b.i))
209
+ const a = authorsOf(receipts, (r) => !badIdx.has(receipts.indexOf(r)))
210
+ files.push({ rel, abs: path.join(dir, rel), ...a })
211
+ }
212
+
213
+ const attributed = files.filter((f) => f.last)
214
+ const orphans = files.filter((f) => !f.last)
215
+ const groups = planCommits(attributed)
216
+
217
+ console.log(`room ${room}: ${files.length} files, ${attributed.length} attributed, ${groups.length} agent(s)`)
218
+ for (const g of groups) {
219
+ console.log(` ${g.name} ${g.files.length} file(s)${g.others.length ? ` (+${g.others.length} co-author)` : ''}`)
220
+ for (const f of g.files) console.log(` ${f.rel}`)
221
+ }
222
+ if (orphans.length) console.log(` (${orphans.length} file(s) with no verified receipt -- not committed: ${orphans.map((o) => o.rel).join(', ')})`)
223
+
224
+ if (DRY) { console.log('\n--dry-run: nothing written'); return finish(0) }
225
+ if (!groups.length) { console.error('Q501 nothing attributed to commit'); return finish(1) }
226
+
227
+ const base = flag('base') || git(['rev-parse', '--abbrev-ref', 'HEAD']).out || 'HEAD'
228
+
229
+ // ── HIV-28: DO NOT WRITE OVER A BRANCH CHANGE WE CANNOT ACCOUNT FOR ──────────────────
230
+ //
231
+ // Measured 19 Sep 2026: this tool produced a PR with three clean per-agent commits, real git
232
+ // authorship and signed trailers, which deleted the HIV-27 claim guard, the HIV-14 truncation
233
+ // fix and ~200 lines of tests. It looked perfect. The cause was the copyFileSync below with
234
+ // nothing in front of it.
235
+ //
236
+ // The check runs BEFORE `checkout -b`, so a refusal leaves no branch behind. Refusing after
237
+ // creating it would hand the operator a half-made artefact to clean up, and the next thing a
238
+ // person does with a branch that exists is push it.
239
+ const decisions = []
240
+ for (const g of groups) {
241
+ for (const f of g.files) {
242
+ // `git cat-file blob` and not `git show`: cat-file reads the object as stored, with no
243
+ // smudge filter. With core.autocrlf on -- true in this repo -- `show` through a working
244
+ // tree hands back bytes git never stored, and every content hash misses.
245
+ const sha = git(['rev-parse', `${base}:${f.rel}`])
246
+ const branchText = sha.code === 0 ? gitRaw(['cat-file', 'blob', sha.out]).out : null
247
+ const roomText = fs.readFileSync(f.abs, 'utf8')
248
+ decisions.push({ ...accountFor({ rel: f.rel, branchText, roomText, receipts: hive.provenanceOf(f.rel) || [] }), _f: f })
249
+ }
250
+ }
251
+ const v = verdict(decisions)
252
+ if (v.identical.length) console.log(` (${v.identical.length} file(s) already identical on ${base} -- nothing to write)`)
253
+
254
+ if (!v.ok) {
255
+ const lastTouch = (rel) => git(['log', '-1', '--format=%h %an %ad %s', '--date=short', base, '--', rel]).out
256
+ console.error('')
257
+ console.error(explain(v.blocked, lastTouch))
258
+ if (!FORCE) return finish(1)
259
+ // A forced run is still reported in full, and the reason travels into the PR body. The
260
+ // point is not to make forcing hard, it is to make it visible.
261
+ console.error('')
262
+ console.error(`--force given: "${FORCE}" -- overwriting ${v.blocked.length} file(s) anyway. This is recorded in the PR body.`)
263
+ }
264
+
265
+ const co = git(['checkout', '-b', BRANCH, base])
266
+ if (co.code !== 0) { console.error(`Q502 could not create branch ${BRANCH}: ${co.err}`); return finish(1) }
267
+
268
+ // IDENTICAL files are dropped rather than copied. Copying one rewrites its line endings when
269
+ // the room and the blob differ in style -- which on this repo is most files -- producing a
270
+ // diff of every line for a change nobody made.
271
+ const skip = new Set(v.identical.map((d) => d.rel))
272
+ let made = 0
273
+ for (const g0 of groups) {
274
+ // THE COMMIT MESSAGE MUST DESCRIBE WHAT WAS COMMITTED. Built from g0.files it said
275
+ // "Builder: 2 files" and listed both while only one was written, because the other was
276
+ // already identical on the branch. A message that names files a commit does not contain is
277
+ // a small false success in the one artefact a reviewer trusts most -- and it is in `git log`
278
+ // forever.
279
+ const g = { ...g0, files: g0.files.filter((f) => !skip.has(f.rel)) }
280
+ if (!g.files.length) { console.log(` (nothing to write from ${g.name} — all already on ${base})`); continue }
281
+ for (const f of g.files) {
282
+ const dest = path.join(REPO, f.rel)
283
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
284
+ fs.copyFileSync(f.abs, dest)
285
+ git(['add', '--', f.rel])
286
+ }
287
+ const msg = commitMessage(g, room)
288
+ const r = git(['commit', '-m', msg, '--author', `${g.name} <${mailOf(g.name)}>`])
289
+ if (r.code !== 0) {
290
+ // "nothing to commit" is not a failure: the room's copy can equal what is already
291
+ // on the branch, and a no-op commit is the honest outcome.
292
+ if (/nothing to commit/i.test(r.out + r.err)) { console.log(` (no change from ${g.name})`); continue }
293
+ console.error(`Q503 commit failed for ${g.name}: ${r.err || r.out}`); return finish(1)
294
+ }
295
+ made++
296
+ }
297
+ console.log(`\nbranch ${BRANCH}: ${made} commit(s)`)
298
+ console.log(git(['log', '--format= %h %an %s', `${base}..${BRANCH}`]).out)
299
+
300
+ if (DO_PUSH) {
301
+ const p = git(['push', '-u', 'origin', BRANCH])
302
+ if (p.code !== 0) { console.error(`Q504 push failed: ${p.err}`); return finish(1) }
303
+ console.log(`pushed ${BRANCH}`)
304
+ }
305
+ if (DO_PR) {
306
+ // A forced overwrite is stated in the PR BODY, not only in the terminal of the person who
307
+ // ran it. The reviewer is the one who needs to know that files were written over branch
308
+ // content this room had never seen -- and a reviewer looking at three clean signed commits
309
+ // has no other way to find that out. That is the whole shape of the 19 Sep incident.
310
+ const forcedNote = (!v.ok && FORCE)
311
+ ? ['',
312
+ '> **Forced past a staleness refusal.** ' + v.blocked.length + ' file(s) were written',
313
+ '> over branch content this room had never seen.',
314
+ '>',
315
+ '> Reason given: ' + JSON.stringify(FORCE),
316
+ '>',
317
+ '> ' + v.blocked.map((d) => d.rel).join(', ')]
318
+ : []
319
+ const body = [`Opened by Hivecode from room \`${room}\`.`, ...forcedNote, '', '| agent | files |', '|---|---|',
320
+ ...groups.map((g) => `| ${g.name} | ${g.files.map((f) => '`' + f.rel + '`').join(', ')} |`),
321
+ '', 'Each commit carries its agent as the git author and the signed author id as a `Hivecode-Author` trailer.'].join('\n')
322
+ const r = spawnSync('gh', ['pr', 'create', '--title', TITLE, '--body', body, '--base', base, '--head', BRANCH],
323
+ { cwd: REPO, encoding: 'utf8' })
324
+ if (r.status !== 0) { console.error(`Q505 gh pr create failed: ${(r.stderr || '').trim()}`); return finish(1) }
325
+ console.log((r.stdout || '').trim())
326
+ }
327
+ finish(0)
328
+ }
329
+
330
+ function finish(code) {
331
+ setTimeout(() => {
332
+ try { hive.stop() } catch { /* going away */ }
333
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch { /* best effort */ }
334
+ process.exit(code)
335
+ }, 400)
336
+ }
337
+ }