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