hivecode-mcp 0.14.2 → 0.14.3

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.
@@ -95,6 +95,7 @@ This guide explains every error code (`Qxxx`) emitted by Hivecode CLI commands,
95
95
  | `Q584` | `hive-repos.js` | Looking up registered repository for room | Repository registration is stale (folder moved/deleted) or multiple local clones are registered for the same room. | Re-bind the correct repository clone using `npx hivecode-mcp branch <branch>`, or remove obsolete entries in `~/.hivecode/repos.json`. |
96
96
  | `Q585` | `hive-repos.js` | Calling `hive_pr` | Not currently connected to a room (`hive_join` has not been called). | Join a room first using `hive_join` before calling `hive_pr`. |
97
97
  | `Q586` | `hive-repos.js` | Calling `hive_pr` | Room connection is currently offline or disconnected from relay. | Wait for the room connection to reconnect to the relay before running `hive_pr`. |
98
+ | `Q587` | `hive-land.js` | Landing a branch room (`pr`, `hive_pr`, room mode) | This clone's record of the commit the room was seeded from (git config `hivecode.<branch>.base`) differs from the room's own record. Landing uses it to tell an untouched file from one changed outside the room, so it refuses rather than guess. | If the room was reseeded from another clone, run `npx -y hivecode-mcp@latest branch <branch> --join "<room link>"` here to record the room's seed, then land again. |
98
99
  | `Q650` | `hive-room-mode.js` | Starting room-mode watcher (`github start --room "<link>"`) | Missing, empty, or malformed room join link. | Provide a valid room join link (`wss://<relay>|room-<id>`) or use `github start --branch <name>`. |
99
100
  | `Q651` | `hive-room-mode.js` | Starting room-mode watcher | Target directory is not a git repository root (or is the user's home directory). | Run the watcher from inside a valid git repository checkout. |
100
101
  | `Q652` | `hive-room-mode.js` | Starting room-mode watcher | The specified room is not bound to a git branch in this repository, or multiple branches match. | Run `npx hivecode-mcp branch <branch>` to bind the branch, or launch with `github start --branch <branch>`. |
package/hive-branch.js CHANGED
@@ -385,6 +385,67 @@ async function roomSeed(link, you) {
385
385
  }
386
386
  }
387
387
 
388
+ // A NEW BRANCH IS CUT FROM WHAT THE TEAM HAS, NOT FROM A STALE COPY OF IT (P1-7, 9 Nov 2026).
389
+ // `branch fix-x` cuts from HEAD, and HEAD is usually a local main that was last pulled days ago: the
390
+ // room was then seeded with old files, agents worked on them, and landing met the newer main as a
391
+ // conflict (or a Q506) nobody had caused. So when the source is a LOCAL branch, its remote copy is
392
+ // fetched first, and:
393
+ // - local strictly BEHIND remote: cut from the remote. Nothing is lost (the remote holds every
394
+ // local commit) and the note says so, with the count.
395
+ // - local AHEAD of remote: cut from local. Those commits are the developer's own work.
396
+ // - DIVERGED: cut from local, and the note says how far behind and how to choose the remote.
397
+ // - the fetch failed (offline, no access): cut from local, and the note says it could not check.
398
+ // A sha or a remote ref (`origin/main`) is taken exactly as given: whoever names one chose it, and
399
+ // the GitHub watcher fetches before it passes one. HIVE_BRANCH_FETCH=0 skips the fetch and compares
400
+ // against the remote ref as last fetched.
401
+ function fetchBranch(root, remote, rb) {
402
+ if (process.env.HIVE_BRANCH_FETCH === '0') return { ok: false, skipped: true, err: 'HIVE_BRANCH_FETCH=0' }
403
+ const r = spawnSync('git', ['fetch', '--quiet', '--no-tags', remote, `+refs/heads/${rb}:refs/remotes/${remote}/${rb}`], {
404
+ cwd: root, encoding: 'utf8', timeout: 30000, windowsHide: true, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
405
+ })
406
+ if (r.error) return { ok: false, err: r.error.code === 'ETIMEDOUT' ? 'timed out after 30 s' : r.error.message }
407
+ if (r.status === 0) return { ok: true }
408
+ const err = String(r.stderr || '').trim()
409
+ // The remote has no such branch (a main never pushed): nothing to be behind, nothing to say.
410
+ if (/couldn't find remote ref|could not find remote ref/i.test(err)) return { ok: false, absent: true, err }
411
+ return { ok: false, err: err.split('\n').pop() || `exit ${r.status}` }
412
+ }
413
+
414
+ /** Where a new branch is cut from: { sha, src, note }. See the comment above. */
415
+ export function freshCutPoint(root, from = '') {
416
+ const src = from || 'HEAD'
417
+ const sha = revParse(root, `${src}^{commit}`)
418
+ if (!sha) return { sha: '', src, note: '' }
419
+ let local = ''
420
+ if (src === 'HEAD') { const c = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']); local = c.code === 0 ? c.out : '' }
421
+ else if (!/^[0-9a-f]{7,40}$/i.test(src) && revParse(root, `refs/heads/${src}`)) local = src
422
+ if (!local) return { sha, src, note: '' } // detached HEAD, a sha, a tag or a remote ref: exactly as given
423
+ // Its remote copy: the configured upstream, else the same name on origin.
424
+ let remote = '', rb = ''
425
+ const up = git(root, ['for-each-ref', '--format=%(upstream:remotename)%09%(upstream:short)', `refs/heads/${local}`])
426
+ const [upRemote, upShort] = (up.code === 0 ? up.out : '').split('\t')
427
+ if (upRemote && upShort && upShort.startsWith(`${upRemote}/`)) { remote = upRemote; rb = upShort.slice(upRemote.length + 1) }
428
+ else if (git(root, ['remote']).out.split('\n').includes('origin')) { remote = 'origin'; rb = local }
429
+ if (!remote) return { sha, src, note: '' } // a repository with no remote: local is all there is
430
+ const rref = `${remote}/${rb}`
431
+ const f = fetchBranch(root, remote, rb)
432
+ const rsha = revParse(root, `refs/remotes/${rref}^{commit}`)
433
+ if (!rsha) {
434
+ return { sha, src, note: f.ok || f.skipped || f.absent ? '' : `could not fetch ${rref} (${f.err}), so ${local} was not checked against it.` }
435
+ }
436
+ const unchecked = f.ok || f.skipped ? '' : ` (could not fetch it just now: ${f.err}; this is ${rref} as last fetched)`
437
+ if (rsha === sha) return { sha, src, note: unchecked ? `${local} matches ${rref}${unchecked}.` : '' }
438
+ const isAnc = (a, b) => git(root, ['merge-base', '--is-ancestor', a, b]).code === 0
439
+ const count = (range) => Number(git(root, ['rev-list', '--count', range]).out) || 0
440
+ if (isAnc(sha, rsha)) {
441
+ const n = count(`${sha}..${rsha}`)
442
+ return { sha: rsha, src: rref, behind: n, note: `your ${local} was ${n} commit(s) behind ${rref}${unchecked}, so the branch was cut from ${rref} (${rsha.slice(0, 7)}) instead. Nothing of yours is left out: ${rref} has every commit your ${local} has.` }
443
+ }
444
+ if (isAnc(rsha, sha)) return { sha, src, ahead: count(`${rsha}..${sha}`), note: unchecked ? `cut from your ${local}${unchecked}.` : '' }
445
+ const behind = count(`${sha}..${rsha}`), ahead = count(`${rsha}..${sha}`)
446
+ return { sha, src, behind, ahead, note: `your ${local} and ${rref} have diverged (${behind} behind, ${ahead} ahead)${unchecked}. The branch was cut from your ${local}; to cut from ${rref} instead, delete the branch and the room binding and run again with --from ${rref}.` }
447
+ }
448
+
388
449
  /** The branch a PR from `name` goes into: the checkout's current branch, else origin's default. */
389
450
  function defaultBase(root, name) {
390
451
  const cur = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
@@ -461,9 +522,13 @@ export async function bindBranch({ cwd, branch, from = '', relay = '', you = '',
461
522
  let tip = revParse(root, `refs/heads/${name}`)
462
523
  // The PR's base is a BRANCH NAME for `gh`: a sha names no branch, and origin/main means main.
463
524
  let fromName = from && !/^[0-9a-f]{7,40}$/i.test(from) ? from.replace(/^origin\//, '') : ''
525
+ let cutNote = ''
464
526
  if (!tip) {
465
527
  const src = from || 'HEAD'
466
- const sha = revParse(root, `${src}^{commit}`)
528
+ const cut = freshCutPoint(root, from)
529
+ const sha = cut.sha
530
+ cutNote = cut.note || ''
531
+ if (cutNote) log(cutNote)
467
532
  if (!sha) {
468
533
  if (src === 'HEAD' && !revParse(root, 'HEAD')) {
469
534
  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.`)
@@ -502,7 +567,7 @@ export async function bindBranch({ cwd, branch, from = '', relay = '', you = '',
502
567
  }
503
568
  writeBinding(root, name, { room: link, from: fromName, base: tip, seed: seeded.dir, files: String(seeded.count) })
504
569
  await registerLocal(root, name, link)
505
- return { root, branch: name, link, you: me, from: fromName, commit: tip, created, reseeded: reseed, files: seeded.count, total: seeded.total, seedDir: seeded.dir }
570
+ return { root, branch: name, link, you: me, from: fromName, commit: tip, created, cutNote, reseeded: reseed, files: seeded.count, total: seeded.total, seedDir: seeded.dir }
506
571
  }
507
572
 
508
573
  // ---- landing: the room's work becomes commits on the branch ---------------------------------
@@ -594,6 +659,7 @@ export async function main(argv, { cwd = process.cwd(), log = console.log, logEr
594
659
  const shown = dirtyFiles.slice(0, 5).join(', ')
595
660
  const more = dirtyFiles.length > 5 ? ` (+${dirtyFiles.length - 5} more)` : ''
596
661
  const uncommittedPart = dirtyFiles.length ? `\n ${dirtyFiles.length} file(s) with uncommitted changes are not in the room: ${shown}${more}` : ''
662
+ if (r.cutNote) log(`Note: ${r.cutNote}`)
597
663
  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}`)
598
664
  if (/onrender\.com/.test(r.link)) log(' This is the public relay: anyone who has this link can read these files.')
599
665
  const roomName = parseLink(r.link).room
package/hive-land.js CHANGED
@@ -185,6 +185,15 @@ export async function landAndOpen(opts = {}) {
185
185
  if (b.base && !HB.revParse(root, `${b.base}^{commit}`)) {
186
186
  return refuse('Q563', `the commit this room was seeded from (${short(b.base)}) is not in this clone, so the room's deletions cannot be accounted for. Fetch it (git fetch origin), then land again. Nothing was changed.`)
187
187
  }
188
+ // THE SEED THIS CLONE RECORDED MUST BE THE ONE THE ROOM RECORDED (0.14.3 P0-3-H). --seed tells
189
+ // hive-pr that a branch file still equal to the seed's copy is one the room started from. If this
190
+ // clone's record (git config, written when the room was made or joined here) names another commit
191
+ // -- the room was reseeded from another clone, or the record was edited -- a file a person changed
192
+ // on the branch after the room's real seed could match it and be landed over without a word.
193
+ // Two records that disagree are not resolved by guessing which is right.
194
+ if (b.base && snap.seed && snap.seed !== b.base) {
195
+ return refuse('Q587', `this clone records that the room for ${name} was seeded from ${short(b.base)}, but the room records ${short(snap.seed)}. Landing trusts that record to tell an untouched file from one changed outside the room, so it will not guess which is right. Nothing was changed.\nIf the room was reseeded elsewhere, record the room's seed here, then land again:\n npx -y hivecode-mcp@latest branch ${name} --join "<the room link>"`)
196
+ }
188
197
  if (!b.base) {
189
198
  // A binding with no seed record (a `--join` made before 0.13.1, or a room seeded before it):
190
199
  // deletions cannot be told from files added to the branch later, so none are landed -- and
@@ -206,6 +215,7 @@ export async function landAndOpen(opts = {}) {
206
215
  if (dry) args.push('--dry-run')
207
216
  if (o.force) args.push('--force', String(o.force))
208
217
  if (o.only) args.push('--only', String(o.only))
218
+ if (b.base) args.push('--seed', String(b.base)) // a file unchanged since the seed is one the room started from (0.14.3 P0-3)
209
219
  // ASYNC, not spawnSync: inside the hive_pr tool this runs in the agent's MCP server, and a landing
210
220
  // that blocked its event loop for minutes would freeze that agent's own room connection too.
211
221
  const r = await runNode(args, { cwd: tmp, env: HB.noHooksEnv({ ...process.env, HIVE_PROVENANCE: 'on' }) })
@@ -460,7 +470,11 @@ async function roomSnapshot(link) {
460
470
  const holders = makeCoordinator(peek.doc.getMap('claims'), 'hive-land-peek').board()
461
471
  .filter((c) => c && c.by)
462
472
  .map((c) => ({ file: c.region, by: c.by, intent: c.intent || 'edit', leftSec: Math.max(0, Math.round(((Number(c.at) || 0) + (Number(c.ttl) || 0) - now) / 1000)) }))
463
- return { paths: new Set(peek.doc.getMap('manifest').keys()), holders }
473
+ // The commit the room records it was seeded from (hive-branch seedRoom writes it): checked against
474
+ // this clone's binding before --seed is trusted (0.14.3 P0-3-H).
475
+ const sc = peek.doc.getMap('seed').get('commit')
476
+ const seed = typeof sc === 'string' && /^[0-9a-f]{40}$/i.test(sc) ? sc : ''
477
+ return { paths: new Set(peek.doc.getMap('manifest').keys()), holders, seed }
464
478
  } finally {
465
479
  try { peek.stop() } catch { }
466
480
  try { fs.rmSync(dir, { recursive: true, force: true }) } catch { }
package/hive-mcp.js CHANGED
@@ -1779,9 +1779,14 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1779
1779
  firstTime('withheld:' + r.file) // said here, so the next hive_wait does not say it again
1780
1780
  return text(`⛔ wrote ${r.file} in YOUR folder only. It was NOT shared, because it contains ${r.withheld}, and Hivecode does not send keys to the room or the relay. Nobody else has this change. Read the key from the environment instead (put it in .env, which is never shared); the file is shared on its next save without the key.`)
1781
1781
  }
1782
- return text(r.conflicted
1783
- ? `wrote ${r.file}, but it now carries <<<<<<< conflict markers (a concurrent edit collided). Read the file and resolve before continuing.`
1784
- : `✓ wrote ${r.file} through the medium — your intent is signed into its provenance ledger and the change is syncing to every peer.`)
1782
+ if (r.conflicted) return text(`wrote ${r.file}, but it now carries <<<<<<< conflict markers (a concurrent edit collided). Read the file and resolve before continuing.`)
1783
+ // SAY WHETHER IT REACHED THE RELAY, never just "syncing" (0.14.3 P0-1). Over HTTPS long-poll an
1784
+ // edit waits in this process until the relay accepts it; an agent that left on "syncing" lost it.
1785
+ const hiveS = requireSession().hive
1786
+ const sent = typeof hiveS.whenSent === 'function' ? await hiveS.whenSent(r.file, 3000) : true
1787
+ return text(sent
1788
+ ? `✓ wrote ${r.file} through the medium — your intent is signed into its provenance ledger and the relay has the change; every peer gets it from there.`
1789
+ : `⏳ wrote ${r.file} in your folder, but the relay has NOT accepted the change yet (slow or reconnecting link). It is still being sent: stay in the room (do not exit or leave) and check hive_status before you report it done.`)
1785
1790
  }
1786
1791
  case 'hive_fork': {
1787
1792
  const file = String(args.file || ''); if (!file) return err('need a file path')
@@ -1876,7 +1881,17 @@ Nothing was written. This tool exists to guarantee the change syncs to every pee
1876
1881
  // row is dropped rather than left to read as GONE. GONE means "stopped without saying so",
1877
1882
  // and diluting it with agents that signed off correctly is how a signal stops being read.
1878
1883
  case 'hive_pr': return text(await runHivePr(args))
1879
- case 'hive_leave': { try { ACT.forget(activityMap(), requireSession().name) } catch { }; requireSession().hive.stop(); session = null; forgetSession(); return text('left the room') }
1884
+ case 'hive_leave': {
1885
+ try { ACT.forget(activityMap(), requireSession().name) } catch { }
1886
+ const h = requireSession().hive
1887
+ // Leave waits until the relay has every edit and message (0.14.3 P0-1), and names what it did not get.
1888
+ const r = typeof h.leave === 'function' ? await h.leave({ timeoutMs: 10000 }) : (h.stop(), null)
1889
+ session = null; forgetSession()
1890
+ const missed = r ? [...r.unsent, ...(r.roomUnsent ? ['your last room messages'] : [])] : []
1891
+ return missed.length
1892
+ ? text(`left the room, but the relay had NOT accepted these when it stopped waiting (10 s): ${missed.join(', ')}. Nobody else has those changes. Rejoin and check them before you report the work done.`)
1893
+ : text('left the room')
1894
+ }
1880
1895
  default: return err(`unknown tool: ${name}`)
1881
1896
  }
1882
1897
  } catch (e) { return err(`error: ${e.message}`) }
@@ -2006,9 +2021,37 @@ function missingRequired(req) {
2006
2021
  return lines.join('\n')
2007
2022
  }
2008
2023
 
2024
+ // [!] MEANS "BEFORE YOU CONTINUE", SO IT IS CHECKED BEFORE THE CALL, NOT AFTER IT (P1-8, 9 Nov 2026).
2025
+ // Mail reached an agent only as a notice appended to a tool's RESULT, after the tool had run. In the
2026
+ // 9 Nov mock run Claude sent "[!] @antigravity-VM Stand down on test.js: ... Don't edit it."; the
2027
+ // agent's next call was hive_edit test.js, the file was written, and "[!] URGENT MAIL" came back in
2028
+ // the reply to that very write. The room read it as "delivered to one of two agents". So a call that
2029
+ // writes, takes work or lands it is refused while urgent mail addressed to this agent is unread, and
2030
+ // the refusal shows the message. Reading it (hive_check or hive_wait) is the decision; then retry.
2031
+ // Releasing, talking and reading are never held: a stand-down usually asks for exactly those.
2032
+ const URGENT_GATED = new Set(['hive_edit', 'hive_claim', 'hive_resolve', 'hive_fork', 'hive_pr', 'hive_set_state'])
2033
+ function urgentGate(name) {
2034
+ try {
2035
+ if (!URGENT_GATED.has(name) || !session || !session.mailbox) return ''
2036
+ catchUp(session)
2037
+ const m = session.mailbox.peek(chatArray())
2038
+ const urgent = (m.mentions || []).filter((x) => x && priorityOf(x.text) === 'stop')
2039
+ if (!urgent.length) return ''
2040
+ const shown = urgent.slice(-3).map((x) => ` ${formatMessage(x)}`.slice(0, 600)).join('\n')
2041
+ return [
2042
+ `REFUSED -- ${urgent.length} URGENT message(s) ([!]) addressed to you are unread, and [!] means: handle it before you continue.`,
2043
+ `Nothing was done. This ${name} call did NOT happen.`,
2044
+ shown,
2045
+ `Read it with hive_check (that marks it read), do what it asks, then call ${name} again if it still applies.`,
2046
+ ].join('\n')
2047
+ } catch { return '' }
2048
+ }
2049
+
2009
2050
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
2010
2051
  const bad = missingRequired(req)
2011
2052
  if (bad) return { content: [{ type: 'text', text: bad }], isError: true }
2053
+ const held = urgentGate(req.params && req.params.name)
2054
+ if (held) return { content: [{ type: 'text', text: held }], isError: true }
2012
2055
  const res = await dispatch(req)
2013
2056
  // Never let the banner break a tool: append only to a well-formed text result, and skip the two
2014
2057
  // tools that already deliver mail themselves (appending would double-report the same messages).
@@ -2078,8 +2121,18 @@ if (isEntryPoint && process.argv[2] === 'setup') {
2078
2121
  // room and exit. The SDK kept this process alive on its open sockets instead, holding a seat
2079
2122
  // and a presence entry for a member that was gone, until something killed it.
2080
2123
  server.onclose = () => {
2081
- try { if (session) session.hive.stop() } catch { /* leaving is best-effort; exiting is not */ }
2082
- setTimeout(() => process.exit(0), 500)
2124
+ // Leave, not stop: the last edits and messages go out before the process does (0.14.3 P0-1). Over
2125
+ // HTTPS long-poll they are still in this process; a 500 ms exit dropped them. Capped, so a dead
2126
+ // link cannot hold a departed agent's seat for long; whatever did not go is named on stderr.
2127
+ const done = () => process.exit(0)
2128
+ setTimeout(done, 12000).unref()
2129
+ const s = session
2130
+ if (!s) return setTimeout(done, 500)
2131
+ Promise.resolve()
2132
+ .then(() => (typeof s.hive.leave === 'function' ? s.hive.leave({ timeoutMs: 10000 }) : (s.hive.stop(), null)))
2133
+ .then((r) => { if (r && (r.unsent.length || r.roomUnsent)) console.error(`[hive-mcp] left before the relay accepted: ${[...r.unsent, ...(r.roomUnsent ? ['(room messages)'] : [])].join(', ')}`) })
2134
+ .catch(() => { /* leaving is best-effort; exiting is not */ })
2135
+ .finally(() => setTimeout(done, 200))
2083
2136
  }
2084
2137
  await server.connect(transport)
2085
2138
  startUpdateCheck() // background, short timeout; hive_join and hive_status show what it finds
package/hive-mux.js CHANGED
@@ -36,11 +36,13 @@ class FileProvider extends ObservableV2 {
36
36
  }
37
37
  get synced() { return !!(this._impl && this._impl.synced) }
38
38
  get wsconnected() { return !!(this._impl && this._impl.wsconnected) }
39
+ /** Bytes handed to this file's socket that the relay has not accepted yet (0.14.3 P0-1). */
40
+ get bufferedAmount() { return (this._impl && this._impl.bufferedAmount) || 0 }
39
41
  get transport() { return this._impl ? this._impl.transport : 'pending' }
40
42
  _bindLegacy(relay, wsOpts) {
41
43
  const p = new WebsocketProvider(relay, this.docName, this.doc, wsOpts)
42
44
  for (const ev of ['sync', 'status', 'connection-error', 'connection-close']) p.on(ev, (...args) => this.emit(ev, args))
43
- this._impl = { transport: 'socket', get synced() { return p.synced }, get wsconnected() { return p.wsconnected }, destroy: () => p.destroy() }
45
+ this._impl = { transport: 'socket', get synced() { return p.synced }, get wsconnected() { return p.wsconnected }, get bufferedAmount() { return (p.ws && p.ws.bufferedAmount) || 0 }, destroy: () => p.destroy() }
44
46
  }
45
47
  destroy() {
46
48
  if (this.destroyed) return
@@ -73,6 +75,7 @@ class Channel {
73
75
  doc.on('update', this._onUpdate)
74
76
  }
75
77
  get wsconnected() { return this.opened && this.mux.connected }
78
+ get bufferedAmount() { return (this.mux.ws && this.mux.ws.bufferedAmount) || 0 }
76
79
  sendOpen() {
77
80
  if (this.destroyed || !this.mux.connected) return
78
81
  clearTimeout(this.retryTimer); this.retryTimer = null
package/hive-pr.js CHANGED
@@ -36,7 +36,7 @@ import path from 'path'
36
36
  import { spawnSync } from 'child_process'
37
37
  import { fileURLToPath } from 'url'
38
38
  import { startSync, parseLink } from './sync.js'
39
- import { accountFor, verdict, explain, DIVERGED, sameText } from './hive-pr-staleness.js'
39
+ import { accountFor, verdict, explain, DIVERGED, DESCENDS, UNPROVEN, sameText } from './hive-pr-staleness.js'
40
40
  const toLF = (s) => (typeof s === 'string' && s.indexOf('\r\n') !== -1 ? s.replace(/\r\n/g, '\n') : s)
41
41
  import { contentHash } from './substrate.js'
42
42
 
@@ -48,6 +48,8 @@ const has = (k) => argv.includes('--' + k)
48
48
  // required reason has to be written down, ends up in the PR body, and is the thing somebody asks
49
49
  // about later. hive_release already works this way for exactly the same argument.
50
50
  const FORCE = flag('force')
51
+ // --seed <commit>: the commit a branch room was seeded from (hive-land passes the binding's record).
52
+ const SEED = flag('seed')
51
53
 
52
54
  const REPO = flag('repo')
53
55
  const DRY = has('dry-run')
@@ -107,7 +109,20 @@ export function sanitizeIntent(raw) {
107
109
  return clean.length > 200 ? clean.slice(0, 200).trim() : clean
108
110
  }
109
111
 
112
+ // An intent that already starts with the agent's own name ("Claude-cloud: Issue #6 ...", the way
113
+ // agents often phrase them) must not be prefixed again: the mock run's commit read
114
+ // "Claude-cloud: Claude-cloud: Issue #6 ..." (0.14.3 P1-9). Case-insensitive, repeated prefixes too.
115
+ export function withoutOwnName(agent, intent) {
116
+ let s = String(intent || '')
117
+ const a = String(agent || '').trim()
118
+ if (!a) return s
119
+ const re = new RegExp(`^\\s*@?${a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:\\s*`, 'i')
120
+ for (let i = 0; i < 5 && re.test(s); i++) s = s.replace(re, '')
121
+ return s.trim() || String(intent || '').trim()
122
+ }
123
+
110
124
  export function truncateSubject(agent, intent, max = 72) {
125
+ intent = withoutOwnName(agent, intent)
111
126
  const prefix = `${agent}: `
112
127
  const full = `${prefix}${intent}`
113
128
  if (full.length <= max) return full
@@ -230,7 +245,7 @@ export function commitMessage(g, room) {
230
245
  lines.push(` - ${intent}`)
231
246
  }
232
247
  for (const ci of coauthorIntents) {
233
- lines.push(` - ${ci.name}: ${ci.intent}`)
248
+ lines.push(` - ${ci.name}: ${withoutOwnName(ci.name, ci.intent)}`)
234
249
  }
235
250
  lines.push('')
236
251
  lines.push('Files:')
@@ -391,7 +406,21 @@ if (isMain) {
391
406
  const sha = git(['rev-parse', `${base}:${f.rel}`])
392
407
  const branchText = sha.code === 0 ? gitRaw(['cat-file', 'blob', sha.out]).out : null
393
408
  const roomText = toLF(fs.readFileSync(f.abs, 'utf8'))
394
- decisions.push({ ...accountFor({ rel: f.rel, branchText, roomText, receipts: hive.provenanceOf(f.rel) || [] }), _f: f })
409
+ let d = accountFor({ rel: f.rel, branchText, roomText, receipts: hive.provenanceOf(f.rel) || [] })
410
+ // UNCHANGED ON THE BRANCH SINCE THE ROOM WAS SEEDED FROM IT (0.14.3 P0-3). A branch room
411
+ // starts as the branch at the seed commit, so a file whose branch copy is still that exact blob
412
+ // is one the room provably held at its start: writing the room's copy over it deletes nothing
413
+ // the room has not seen, whatever its receipts say. demo-3 (9 Nov) was refused here (Q506) on
414
+ // an untouched branch because one unsigned edit had cut test.js's chain off from the seed.
415
+ // Only a broken or missing chain is overruled; a file the branch changed since is still judged
416
+ // by its chain alone, so a real change under the room is still refused.
417
+ if (SEED && (d.state === DIVERGED || d.state === UNPROVEN) && sha.code === 0) {
418
+ const atSeed = git(['rev-parse', `${SEED}:${f.rel}`])
419
+ if (atSeed.code === 0 && atSeed.out === sha.out) {
420
+ d = { ...d, state: DESCENDS, why: `unchanged on the branch since the room was seeded from it (${SEED.slice(0, 7)}), so the room started from exactly this copy` }
421
+ }
422
+ }
423
+ decisions.push({ ...d, _f: f })
395
424
  }
396
425
  }
397
426
  const v = verdict(decisions)
@@ -417,7 +446,7 @@ if (isMain) {
417
446
  }
418
447
  }
419
448
 
420
- if (DRY) {
449
+ if (DRY && !groups.length) {
421
450
  writePlanJson()
422
451
  console.log('\n--dry-run: nothing written')
423
452
  return finish(0)
@@ -428,6 +457,9 @@ if (isMain) {
428
457
  return finish(1)
429
458
  }
430
459
 
460
+ // A DRY RUN ANSWERS WHAT THE REAL ONE WILL (0.14.3). It used to return "would land" before this
461
+ // check, so the watcher's dry pass went green and the real landing then refused with Q506, and
462
+ // nobody heard (9 Nov, issue #9). The same verdict, the same refusal, nothing written either way.
431
463
  if (!v.ok) {
432
464
  writePlanJson()
433
465
  const lastTouch = (rel) => git(['log', '-1', '--format=%h %an %ad %s', '--date=short', base, '--', rel]).out
@@ -437,7 +469,12 @@ if (isMain) {
437
469
  // A forced run is still reported in full, and the reason travels into the PR body. The
438
470
  // point is not to make forcing hard, it is to make it visible.
439
471
  console.error('')
440
- console.error(`--force given: "${FORCE}" -- overwriting ${v.blocked.length} file(s) anyway. This is recorded in the PR body.`)
472
+ console.error(`--force given: "${FORCE}" -- ${DRY ? 'would overwrite' : 'overwriting'} ${v.blocked.length} file(s) anyway. This is recorded in the PR body.`)
473
+ }
474
+ if (DRY) {
475
+ writePlanJson()
476
+ console.log('\n--dry-run: nothing written')
477
+ return finish(0)
441
478
  }
442
479
 
443
480
  const co = git(['checkout', '-b', BRANCH, base])
package/hive-room-mode.js CHANGED
@@ -460,7 +460,11 @@ export function filterRoomMembers(rawMembers = []) {
460
460
 
461
461
  for (const m of rawMembers) {
462
462
  if (!m || !m.name) continue
463
- if (m.name === 'GitHub' || m.passive || m.listener) continue
463
+ // A LISTENER IS THE AGENT, NOT A BOT (P1-6, 9 Nov 2026). hive-await joins under the agent's own
464
+ // name, tagged `listener` and deliberately NOT passive, and presence is merged by name: every
465
+ // agent that keeps its listener armed (the room's rule) came back with listener: true and was
466
+ // dropped here, so the first issue comment said "In the room now: none yet" with agents present.
467
+ if (m.name === 'GitHub' || m.passive) continue
464
468
  // Exclude Recorder or ControlRoom viewer
465
469
  if (/recorder|control[-_ ]?room/i.test(m.name) || /control[-_ ]?room/i.test(m.app || '')) continue
466
470
 
@@ -842,6 +846,76 @@ function evaluateFileReview(hive, rel, plan = [], hashes = null) {
842
846
  }
843
847
  }
844
848
 
849
+ /**
850
+ * The passive "GitHub-done" connection whenDone reads the room through. Its own function so that
851
+ * landIssue can open it, hand it to whenDone, and keep it open for the Q576 fix task: whenDone
852
+ * stops a connection it opened itself, and 0.14.2 then wrote the fix task and the room warning
853
+ * into that stopped doc, so the room never got them (0.14.3 P0-2 (b)).
854
+ */
855
+ export async function openDoneHive({ link, token } = {}) {
856
+ const parsed = parseLink(link)
857
+ if (!parsed || !parsed.room) return { error: 'invalid room link' }
858
+ const tempDir = path.join(os.tmpdir(), `hive-done-${crypto.randomBytes(6).toString('hex')}`)
859
+ fs.mkdirSync(tempDir, { recursive: true })
860
+ const hive = startSync({
861
+ relay: parsed.relay,
862
+ room: parsed.room,
863
+ dir: tempDir,
864
+ name: 'GitHub-done',
865
+ kind: 'ai',
866
+ passive: true,
867
+ syncFiles: false,
868
+ token: parsed.token || token,
869
+ log: () => {},
870
+ })
871
+ // Wait for sync to establish
872
+ await new Promise((res) => {
873
+ const timer = setTimeout(res, 15000)
874
+ if (hive.provider) {
875
+ if (hive.provider.synced) {
876
+ clearTimeout(timer)
877
+ return res()
878
+ }
879
+ hive.provider.on('sync', (synced) => {
880
+ if (synced) {
881
+ clearTimeout(timer)
882
+ res()
883
+ }
884
+ })
885
+ } else {
886
+ res()
887
+ }
888
+ })
889
+ const close = async ({ settleMs = 0 } = {}) => {
890
+ // A write made just before close must get the chance to leave this process (the same family
891
+ // as P0-1); the watcher's own confirmed room line is the guarantee, this is best effort.
892
+ if (settleMs > 0) await new Promise((r) => setTimeout(r, settleMs))
893
+ try { hive.stop() } catch {}
894
+ try { fs.rmSync(tempDir, { recursive: true, force: true }) } catch {}
895
+ }
896
+ return { hive, tempDir, close }
897
+ }
898
+
899
+ /**
900
+ * The branch's head on origin, for the landing fingerprint: a Q506 says the branch moved, and a
901
+ * push that fixes the branch must earn a new landing even when the room did not change (0.14.3
902
+ * P0-2, Claude's review). ls-remote first (the local ref is only as new as the last fetch), the
903
+ * local origin ref when the network does not answer, '' when neither exists.
904
+ */
905
+ export function branchHeadOf(root, branch) {
906
+ if (!root || !branch) return ''
907
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' }
908
+ try {
909
+ const r = spawnSync('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], { cwd: root, encoding: 'utf8', timeout: 15000, windowsHide: true, env })
910
+ const m = r.status === 0 && String(r.stdout || '').match(/^([0-9a-f]{40,64})\s/m)
911
+ if (m) return m[1]
912
+ const l = spawnSync('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`], { cwd: root, encoding: 'utf8', timeout: 15000, windowsHide: true, env })
913
+ return l.status === 0 ? String(l.stdout || '').trim() : ''
914
+ } catch {
915
+ return ''
916
+ }
917
+ }
918
+
845
919
  /**
846
920
  * B3: Done Detection
847
921
  * Evaluates whether an issue task in the room is ready for landing.
@@ -872,42 +946,13 @@ export async function whenDone(opts = {}) {
872
946
  if (syncRunner) {
873
947
  hive = await syncRunner()
874
948
  } else {
875
- const parsed = parseLink(link)
876
- if (!parsed || !parsed.room) {
877
- return { ready: false, why: 'invalid room link', reasons: ['invalid room link'] }
949
+ const opened = await openDoneHive({ link, token })
950
+ if (opened.error) {
951
+ return { ready: false, why: opened.error, reasons: [opened.error] }
878
952
  }
879
- tempDir = path.join(os.tmpdir(), `hive-done-${crypto.randomBytes(6).toString('hex')}`)
880
- fs.mkdirSync(tempDir, { recursive: true })
881
- hive = startSync({
882
- relay: parsed.relay,
883
- room: parsed.room,
884
- dir: tempDir,
885
- name: 'GitHub-done',
886
- kind: 'ai',
887
- passive: true,
888
- syncFiles: false,
889
- token: parsed.token || token,
890
- log: () => {},
891
- })
953
+ hive = opened.hive
954
+ tempDir = opened.tempDir
892
955
  ownedHive = true
893
- // Wait for sync to establish
894
- await new Promise((res) => {
895
- const timer = setTimeout(res, 15000)
896
- if (hive.provider) {
897
- if (hive.provider.synced) {
898
- clearTimeout(timer)
899
- return res()
900
- }
901
- hive.provider.on('sync', (synced) => {
902
- if (synced) {
903
- clearTimeout(timer)
904
- res()
905
- }
906
- })
907
- } else {
908
- res()
909
- }
910
- })
911
956
  }
912
957
 
913
958
  // 1. Task check
@@ -975,6 +1020,11 @@ export async function whenDone(opts = {}) {
975
1020
  const why = dryRes ? dryRes.why : 'dry landing returned false'
976
1021
  return {
977
1022
  ready: false,
1023
+ // A DRY REFUSAL IS A REFUSAL, with its code. Once the dry run answers what the real one will
1024
+ // (0.14.3 P0-3), Q506 arrives HERE, before any real landing: dropping the code made it read as
1025
+ // "not ready yet", and the watcher stays quiet on that by design, so issue #9's silence came
1026
+ // back by the other door (Claude-cloud's review of P0-3).
1027
+ ...(dryRes && dryRes.code ? { refused: true, code: dryRes.code } : {}),
978
1028
  why: `dry landing check refused: ${why}`,
979
1029
  reasons: [why],
980
1030
  task,
@@ -988,6 +1038,9 @@ export async function whenDone(opts = {}) {
988
1038
  // 4. Review status check for every planned file
989
1039
  const reasons = []
990
1040
  const unreviewedFiles = []
1041
+ // Who reviewed each landing file, from the reviews bound to the exact content that lands: the PR
1042
+ // body names them (0.14.3 P1-10; the mock run's PR #7 did not say who reviewed it).
1043
+ const reviewed = []
991
1044
  let hasHardBlock = false
992
1045
 
993
1046
  for (const rel of plannedFiles) {
@@ -1004,6 +1057,9 @@ export async function whenDone(opts = {}) {
1004
1057
  unreviewedFiles.push(rel)
1005
1058
  if (!allowUnreviewed) reasons.push(`${rel} has not been reviewed`)
1006
1059
  }
1060
+ } else if (revStatus.verdict === 'pass') {
1061
+ const by = [...new Set((revStatus.reviews || []).filter((r) => r && r.verdict === 'pass' && r.by).map((r) => String(r.by)))]
1062
+ reviewed.push({ file: rel, by })
1007
1063
  } else if (revStatus.verdict === 'fail') {
1008
1064
  hasHardBlock = true
1009
1065
  reasons.push(`${rel} has an unresolved fail review`)
@@ -1038,6 +1094,7 @@ export async function whenDone(opts = {}) {
1038
1094
  return {
1039
1095
  ready: true,
1040
1096
  unreviewedFiles,
1097
+ reviewed,
1041
1098
  files: plannedFiles,
1042
1099
  deletions,
1043
1100
  task,
@@ -1062,6 +1119,19 @@ export async function whenDone(opts = {}) {
1062
1119
  * Implements plan fingerprint caching to avoid retry loops on failing tests.
1063
1120
  */
1064
1121
  export async function landIssue(opts = {}) {
1122
+ if (opts.syncRunner) return await landIssueOn(opts)
1123
+ const opened = await openDoneHive({ link: opts.link, token: opts.token })
1124
+ if (opened.error) return { ok: false, why: `Not ready to land: ${opened.error}`, reasons: [opened.error] }
1125
+ let res = null
1126
+ try {
1127
+ res = await landIssueOn({ ...opts, syncRunner: async () => opened.hive })
1128
+ return res
1129
+ } finally {
1130
+ await opened.close({ settleMs: res && res.toldRoom ? 1500 : 0 })
1131
+ }
1132
+ }
1133
+
1134
+ async function landIssueOn(opts = {}) {
1065
1135
  const {
1066
1136
  link,
1067
1137
  taskId,
@@ -1073,6 +1143,7 @@ export async function landIssue(opts = {}) {
1073
1143
  allowUnreviewed = false,
1074
1144
  state = {},
1075
1145
  token,
1146
+ headOf = branchHeadOf,
1076
1147
  } = opts
1077
1148
 
1078
1149
  // 1. Check readiness
@@ -1088,6 +1159,11 @@ export async function landIssue(opts = {}) {
1088
1159
  })
1089
1160
 
1090
1161
  if (!wd.ready) {
1162
+ // Refused at the dry run (a coded refusal, not "not done yet"): handed back WITH its code, so the
1163
+ // watcher says it once, on the issue and in the room, like a refusal of the real landing.
1164
+ if (wd.refused && wd.code) {
1165
+ return { ok: false, code: wd.code, why: wd.why, reasons: wd.reasons, refusedAt: 'dry' }
1166
+ }
1091
1167
  return {
1092
1168
  ok: false,
1093
1169
  ...(wd.blocked === 'unreviewed' ? { blocked: 'unreviewed', files: wd.unreviewedFiles || wd.files || [] } : {}),
@@ -1098,7 +1174,10 @@ export async function landIssue(opts = {}) {
1098
1174
 
1099
1175
  // 2. Plan fingerprint (Claude addition a: NO RETRY LOOP)
1100
1176
  const hive = wd.hive
1101
- const fingerprint = computePlanFingerprint(wd.plan, wd.files, hive, wd.hashes)
1177
+ // The branch head is part of it: the same room work against a branch that moved (or was fixed)
1178
+ // is a new landing, not a repeat of the failed one.
1179
+ const head = (() => { try { return headOf(root, branch) || '' } catch { return '' } })()
1180
+ const fingerprint = `${computePlanFingerprint(wd.plan, wd.files, hive, wd.hashes)}${head ? `@${head}` : ''}`
1102
1181
 
1103
1182
  if (state.lastFailedFingerprint === fingerprint) {
1104
1183
  return {
@@ -1128,6 +1207,13 @@ export async function landIssue(opts = {}) {
1128
1207
  if (wd.unreviewedFiles && wd.unreviewedFiles.length > 0) {
1129
1208
  summary += `\n\nNot reviewed by a second agent: ${wd.unreviewedFiles.join(', ')}`
1130
1209
  }
1210
+ if (wd.reviewed && wd.reviewed.length > 0) {
1211
+ // Names in code spans with any leading @ dropped: a room name like "@someone" must not ping a
1212
+ // GitHub user from a PR body.
1213
+ const who = (n) => `\`${String(n).replace(/^@+/, '').replace(/`/g, "'").slice(0, 60)}\``
1214
+ summary += '\n\nReviewed in the room (hive_review, bound to the exact content that lands):\n' +
1215
+ wd.reviewed.map((r) => `- ${r.file}: ${r.by.length ? r.by.map(who).join(', ') : 'a reviewer the room could not name'}`).join('\n')
1216
+ }
1131
1217
 
1132
1218
  const landFn = landRunner || landAndOpen
1133
1219
 
@@ -1159,6 +1245,7 @@ export async function landIssue(opts = {}) {
1159
1245
  why: landRes.why,
1160
1246
  fingerprint,
1161
1247
  state: nextState,
1248
+ toldRoom: Boolean(hive && (hive.assign || hive.say)),
1162
1249
  }
1163
1250
  }
1164
1251
 
@@ -1296,7 +1383,13 @@ export function collectPrFeedback(opts = {}) {
1296
1383
  )
1297
1384
  }
1298
1385
  if (issueNumber) {
1299
- streams.push(['ic', () => (gh && gh.comments ? gh.comments(issueNumber) : []), 'comment on the issue'])
1386
+ // A DELETED ISSUE answers 404 on its comments forever; 0.14.2 threw here on every tick, so the
1387
+ // watcher jammed with Q603 until restart while the PR was still open (0.14.3 P1). Gone means
1388
+ // no comments; any other failure (rate limit, 5xx) still throws and is retried next tick.
1389
+ streams.push(['ic', () => {
1390
+ if (!gh || !gh.comments) return []
1391
+ try { return gh.comments(issueNumber) } catch (e) { if (e && e.status === 404) return []; throw e }
1392
+ }, 'comment on the issue'])
1300
1393
  }
1301
1394
 
1302
1395
  const fresh = []