hivecode-mcp 0.14.2 → 0.14.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-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-notify.js CHANGED
@@ -27,6 +27,7 @@ import crypto from 'crypto'
27
27
  import { startSync, parseLink } from './sync.js'
28
28
  import { makeNotifier, scrub } from './hive-webhook.js'
29
29
  import { relayFetch } from './hive-transport.js'
30
+ import * as SEEN from './hive-seen.js' // what was already looked at, by Yjs id (0.14.4)
30
31
 
31
32
  const oneLine = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t }
32
33
  const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
@@ -99,7 +100,14 @@ export function makeWatch({ hive, forNames, title, send, now = () => Date.now(),
99
100
  const owners = hive.doc.getMap('owners')
100
101
  const chat = hive.doc.getArray('chat')
101
102
  const sent = loadSent(stateFile)
102
- let seenChat = chat.length // what was said before this started is not news
103
+ // SEEN BY IDENTITY, NOT BY COUNT (0.14.4). A count missed a late message: one a peer pushed while
104
+ // its update was in flight integrates BEHIND what was already counted, and `slice(seenChat)` never
105
+ // looked there, so a mention of someone away was never notified (hive-seen.js has the incident).
106
+ // A plain array (a test double) is append-only, so the count stays exact for it.
107
+ const ych = SEEN.isYArray(chat)
108
+ const seen = ych ? SEEN.makeFrontier() : null
109
+ if (ych) for (const { id } of SEEN.itemsOf(chat)) seen.add(id) // what was said before this started is not news
110
+ let seenChat = chat.length
103
111
  const buffer = []
104
112
  let lastFlush = 0
105
113
  let lostSince = 0
@@ -142,13 +150,21 @@ export function makeWatch({ hive, forNames, title, send, now = () => Date.now(),
142
150
  }
143
151
 
144
152
  // MESSAGES NAMING THEM, from agents, while they are away: batched.
145
- const all = chat.toArray()
146
- if (all.length < seenChat) seenChat = 0 // the relay restarted and the room's history went with it
147
- for (const m of all.slice(seenChat)) {
153
+ let fresh
154
+ if (ych) {
155
+ // A rebuilt room needs no special case: its messages carry ids this frontier has never seen.
156
+ fresh = []
157
+ for (const { id, value } of SEEN.itemsOf(chat)) { if (seen.has(id)) continue; seen.add(id); fresh.push(value) }
158
+ } else {
159
+ const all = chat.toArray()
160
+ if (all.length < seenChat) seenChat = 0 // the relay restarted and the room's history went with it
161
+ fresh = all.slice(seenChat)
162
+ seenChat = all.length
163
+ }
164
+ for (const m of fresh) {
148
165
  if (!m || m.kind !== 'ai') continue
149
166
  for (const p of people) if (addresses(m.text, p) && !here(p)) { buffer.push({ by: m.by, to: p, text: m.text }); break }
150
167
  }
151
- seenChat = all.length
152
168
  if (buffer.length && t - lastFlush >= every * 60000) {
153
169
  const shown = buffer.slice()
154
170
  const to = [...new Set(shown.map((b) => b.to))].join(', ')
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-receipts.js CHANGED
@@ -53,11 +53,18 @@
53
53
  // and exactly the shape that already bit this repo once as a duplicated reconcile(): two
54
54
  // descriptions of one thing, only one of which is ever checked against reality.
55
55
  import { mentionedNames, mentionsAll } from './hive-mailbox.js'
56
+ import { covers, makeFrontier } from './hive-seen.js'
56
57
 
57
58
  export const READ = 'READ'
58
59
  export const UNREAD = 'UNREAD'
59
60
  export const UNKNOWN = 'UNKNOWN'
60
61
  export const UNREACHABLE = 'UNREACHABLE'
62
+ // HANDED (0.14.4): the recipient's LISTENER took it and woke (or tried to wake) their app, but the
63
+ // agent itself has not read it. Before 0.14.4 the listener published the agent's read cursor, so this
64
+ // was reported as READ -- and an app that was never woken, or crashed first, lost the mail on its next
65
+ // session while the sender was told it had been read (Claude-cloud's attack 6, incident I-1).
66
+ // HANDED is not delivered: a listener holding a message is not an agent knowing it.
67
+ export const HANDED = 'HANDED'
61
68
 
62
69
  /** Only these count as delivered. Anything else is an absence of evidence, not evidence. */
63
70
  export const DELIVERED_STATES = [READ]
@@ -84,6 +91,31 @@ export function publishCursor(cursors, name, at, now = Date.now) {
84
91
  return rec
85
92
  }
86
93
 
94
+ /**
95
+ * Publish my read and handed FRONTIERS (hive-seen.js) into the room: record version 2.
96
+ *
97
+ * read advanced only by the agent's own hive_wait / hive_check / hive_read_chat
98
+ * handed advanced by its listener as well; a read is also handed, so read is merged into it
99
+ * at how many leading messages `read` covers without a gap. Kept so a peer on an older build,
100
+ * which only knows counts, is never told more than was read. It CAN go down, when a late
101
+ * message integrates inside that prefix, and that is the honest answer: no clamp here.
102
+ *
103
+ * Both frontiers only move forward (merged with what is already published). Writes nothing when
104
+ * nothing changed, for the same reason as publishCursor: an identical write wakes every observer.
105
+ */
106
+ export function publishFrontier(cursors, name, { read = null, handed = null, at = null, handedAt = '' } = {}, now = Date.now) {
107
+ if (!cursors || !name) return null
108
+ const prev = cursors.get(String(name))
109
+ const p = prev && prev.ver >= 2 ? prev : null
110
+ const r = makeFrontier(p && p.read); if (read) r.merge(read)
111
+ const h = makeFrontier(p && p.handed); if (handed) h.merge(handed); h.merge(r.toJSON())
112
+ const nextAt = at == null ? ((prev && Number(prev.at)) || 0) : Math.max(0, Number(at) || 0)
113
+ const rec = { ver: 2, at: nextAt, read: r.toJSON(), handed: h.toJSON(), handedAt: handed ? String(handedAt || '') : ((p && p.handedAt) || ''), ts: now() }
114
+ if (p && p.at === rec.at && JSON.stringify(p.read) === JSON.stringify(rec.read) && JSON.stringify(p.handed) === JSON.stringify(rec.handed)) return prev
115
+ cursors.set(String(name), rec)
116
+ return rec
117
+ }
118
+
87
119
  /** Case-insensitive, because @Builder and @builder are one agent everywhere else in this system. */
88
120
  export function cursorOf(cursors, name) {
89
121
  if (!cursors) return null
@@ -117,12 +149,26 @@ export function findIndex(chat, msg) {
117
149
  * Absence outranks a stale cursor for the same reason it does in hive-busy.js: it is the more
118
150
  * actionable of the two.
119
151
  */
120
- export function stateFor({ cursors, name, index, present = true, connected = true }) {
152
+ /**
153
+ * With a message's Yjs id and a version-2 record, the answer comes from the frontiers, so a message
154
+ * that integrated behind a reader's position is UNREAD, never a false READ. Without either, the count
155
+ * rule below still answers -- for a peer on an older build that only publishes counts.
156
+ */
157
+ function byFrontier(cur, id, name) {
158
+ if (!id || !cur || !(cur.ver >= 2) || !cur.read) return null
159
+ if (covers(cur.read, id)) return { name, state: READ, at: cur.ts, why: `@${name} has read it` }
160
+ if (covers(cur.handed, id)) return { name, state: HANDED, at: cur.ts, why: `@${name}'s listener took it${cur.handedAt ? ` at ${cur.handedAt}` : ''}, but ${name} has NOT read it yet` }
161
+ return null
162
+ }
163
+
164
+ export function stateFor({ cursors, name, index, id = null, present = true, connected = true }) {
121
165
  if (!connected) return { name, state: UNREACHABLE, why: 'this client is offline — the message is in your local copy only and has not left this machine' }
122
166
  if (!present) return { name, state: UNREACHABLE, why: `@${name} is not in the room, so nobody is going to read it` }
123
167
  if (index < 0) return { name, state: UNKNOWN, why: 'that message is not in the room log — it may not have synced' }
124
168
  const cur = cursorOf(cursors, name)
125
169
  if (!cur) return { name, state: UNKNOWN, why: `@${name} publishes no read position (an older build, a bridge, or a human client) — silence from them means nothing either way` }
170
+ const f = byFrontier(cur, id, name); if (f) return f
171
+ if (id && cur.ver >= 2 && cur.read) return { name, state: UNREAD, at: cur.ts, why: `@${name} has NOT been handed this one` }
126
172
  // A cursor of N means N messages consumed, i.e. indexes 0..N-1. So the message at `index` has
127
173
  // been handed over exactly when the cursor is strictly greater than it.
128
174
  if (Number(cur.at) > index) return { name, state: READ, at: cur.ts, why: `@${name} has read it` }
@@ -161,10 +207,15 @@ export function stateFor({ cursors, name, index, present = true, connected = tru
161
207
  * design argued in the room over four rounds. Tests are antigravity1's, written before the code
162
208
  * existed and verified red first: tests/transcript-receipts-test.mjs.
163
209
  */
164
- export function transcriptStateFor({ cursors, name, index, present = true }) {
210
+ export function transcriptStateFor({ cursors, name, index, id = null, present = true }) {
165
211
  if (index < 0) return { name, state: UNKNOWN, why: 'that message is not in the room log — it may not have synced' }
166
212
  const cur = cursorOf(cursors, name)
167
213
  if (!cur) return { name, state: UNKNOWN, why: `@${name} publishes no read position (an older build, a bridge, or a human client) — silence from them means nothing either way` }
214
+ const f = byFrontier(cur, id, name); if (f) return f
215
+ if (id && cur.ver >= 2 && cur.read) {
216
+ if (!present) return { name, state: UNREACHABLE, at: cur.ts, why: `@${name} is not in the room, and left without reading this message` }
217
+ return { name, state: UNREAD, at: cur.ts, why: `@${name} has NOT been handed this one` }
218
+ }
168
219
  // A cursor of N means N messages consumed, i.e. indexes 0..N-1. So the message at `index` has
169
220
  // been handed over exactly when the cursor is STRICTLY greater than it. A cursor sitting exactly
170
221
  // at `index` means NOT read; an off-by-one here is a receipt that lies quietly.
@@ -185,7 +236,7 @@ export const countDelivered = (states) => states.filter((s) => DELIVERED_STATES.
185
236
  * `@all` is skipped for the same reason a broadcast creates no reply debt in hive-mailbox.js --
186
237
  * one message obliging eight receipts is the storm, not the signal.
187
238
  */
188
- export function receipts({ chat, cursors, me, present = () => true, connected = true, limit = 10 }) {
239
+ export function receipts({ chat, ids = null, cursors, me, present = () => true, connected = true, limit = 10 }) {
189
240
  const list = Array.isArray(chat) ? chat : []
190
241
  const out = []
191
242
  for (let i = list.length - 1; i >= 0 && out.length < limit; i--) {
@@ -197,13 +248,13 @@ export function receipts({ chat, cursors, me, present = () => true, connected =
197
248
  out.push({
198
249
  at: m.at,
199
250
  text: String(m.text || '').slice(0, 120),
200
- to: named.map((n) => stateFor({ cursors, name: n, index: i, present: present(n), connected })),
251
+ to: named.map((n) => stateFor({ cursors, name: n, index: i, id: ids ? ids[i] : null, present: present(n), connected })),
201
252
  })
202
253
  }
203
254
  return out.reverse()
204
255
  }
205
256
 
206
- const MARK = { [READ]: '✓', [UNREAD]: '…', [UNKNOWN]: '?', [UNREACHABLE]: '✗' }
257
+ const MARK = { [READ]: '✓', [HANDED]: '↪', [UNREAD]: '…', [UNKNOWN]: '?', [UNREACHABLE]: '✗' }
207
258
 
208
259
  /** One line per message. The marks make a column a human can scan; the words carry the meaning. */
209
260
  export function describe(r) {
package/hive-room-mode.js CHANGED
@@ -23,6 +23,7 @@ import crypto from 'crypto'
23
23
  import { spawnSync } from 'child_process'
24
24
  import { parseLink, startSync } from './sync.js'
25
25
  import { landAndOpen, testCommandFor } from './hive-land.js'
26
+ import * as SEEN from './hive-seen.js' // what the room held when the issue was posted, by Yjs id (0.14.4)
26
27
 
27
28
  export const MIN_ROOM_MODE_VERSION = '0.13.0'
28
29
 
@@ -460,7 +461,11 @@ export function filterRoomMembers(rawMembers = []) {
460
461
 
461
462
  for (const m of rawMembers) {
462
463
  if (!m || !m.name) continue
463
- if (m.name === 'GitHub' || m.passive || m.listener) continue
464
+ // A LISTENER IS THE AGENT, NOT A BOT (P1-6, 9 Nov 2026). hive-await joins under the agent's own
465
+ // name, tagged `listener` and deliberately NOT passive, and presence is merged by name: every
466
+ // agent that keeps its listener armed (the room's rule) came back with listener: true and was
467
+ // dropped here, so the first issue comment said "In the room now: none yet" with agents present.
468
+ if (m.name === 'GitHub' || m.passive) continue
464
469
  // Exclude Recorder or ControlRoom viewer
465
470
  if (/recorder|control[-_ ]?room/i.test(m.name) || /control[-_ ]?room/i.test(m.app || '')) continue
466
471
 
@@ -705,6 +710,17 @@ export async function postIssue(opts = {}) {
705
710
  // 3. Post chat message
706
711
  const chatText = formatIssueChat({ issue, waysOfWorking })
707
712
  if (hive.say) hive.say(chatText)
713
+ // Everything this poster held once its own post was in: the watcher counts an agent reply as
714
+ // "since the issue was posted" when its id is NOT in here, wherever it integrates (0.14.4).
715
+ const postFrontier = (() => {
716
+ try {
717
+ const ch = hive.doc && hive.doc.getArray('chat')
718
+ if (!SEEN.isYArray(ch)) return null
719
+ const f = SEEN.makeFrontier()
720
+ for (const { id } of SEEN.itemsOf(ch)) f.add(id)
721
+ return f.toJSON()
722
+ } catch { return null }
723
+ })()
708
724
 
709
725
  // 4. Verify delivery via fresh passive peek
710
726
  const chatMarker = attempt > 1
@@ -735,6 +751,7 @@ export async function postIssue(opts = {}) {
735
751
  verified: true,
736
752
  members: members.agents,
737
753
  comment,
754
+ postFrontier,
738
755
  }
739
756
  } finally {
740
757
  if (hive && typeof hive.stop === 'function') {
@@ -842,6 +859,76 @@ function evaluateFileReview(hive, rel, plan = [], hashes = null) {
842
859
  }
843
860
  }
844
861
 
862
+ /**
863
+ * The passive "GitHub-done" connection whenDone reads the room through. Its own function so that
864
+ * landIssue can open it, hand it to whenDone, and keep it open for the Q576 fix task: whenDone
865
+ * stops a connection it opened itself, and 0.14.2 then wrote the fix task and the room warning
866
+ * into that stopped doc, so the room never got them (0.14.3 P0-2 (b)).
867
+ */
868
+ export async function openDoneHive({ link, token } = {}) {
869
+ const parsed = parseLink(link)
870
+ if (!parsed || !parsed.room) return { error: 'invalid room link' }
871
+ const tempDir = path.join(os.tmpdir(), `hive-done-${crypto.randomBytes(6).toString('hex')}`)
872
+ fs.mkdirSync(tempDir, { recursive: true })
873
+ const hive = startSync({
874
+ relay: parsed.relay,
875
+ room: parsed.room,
876
+ dir: tempDir,
877
+ name: 'GitHub-done',
878
+ kind: 'ai',
879
+ passive: true,
880
+ syncFiles: false,
881
+ token: parsed.token || token,
882
+ log: () => {},
883
+ })
884
+ // Wait for sync to establish
885
+ await new Promise((res) => {
886
+ const timer = setTimeout(res, 15000)
887
+ if (hive.provider) {
888
+ if (hive.provider.synced) {
889
+ clearTimeout(timer)
890
+ return res()
891
+ }
892
+ hive.provider.on('sync', (synced) => {
893
+ if (synced) {
894
+ clearTimeout(timer)
895
+ res()
896
+ }
897
+ })
898
+ } else {
899
+ res()
900
+ }
901
+ })
902
+ const close = async ({ settleMs = 0 } = {}) => {
903
+ // A write made just before close must get the chance to leave this process (the same family
904
+ // as P0-1); the watcher's own confirmed room line is the guarantee, this is best effort.
905
+ if (settleMs > 0) await new Promise((r) => setTimeout(r, settleMs))
906
+ try { hive.stop() } catch {}
907
+ try { fs.rmSync(tempDir, { recursive: true, force: true }) } catch {}
908
+ }
909
+ return { hive, tempDir, close }
910
+ }
911
+
912
+ /**
913
+ * The branch's head on origin, for the landing fingerprint: a Q506 says the branch moved, and a
914
+ * push that fixes the branch must earn a new landing even when the room did not change (0.14.3
915
+ * P0-2, Claude's review). ls-remote first (the local ref is only as new as the last fetch), the
916
+ * local origin ref when the network does not answer, '' when neither exists.
917
+ */
918
+ export function branchHeadOf(root, branch) {
919
+ if (!root || !branch) return ''
920
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' }
921
+ try {
922
+ const r = spawnSync('git', ['ls-remote', '--heads', 'origin', `refs/heads/${branch}`], { cwd: root, encoding: 'utf8', timeout: 15000, windowsHide: true, env })
923
+ const m = r.status === 0 && String(r.stdout || '').match(/^([0-9a-f]{40,64})\s/m)
924
+ if (m) return m[1]
925
+ const l = spawnSync('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`], { cwd: root, encoding: 'utf8', timeout: 15000, windowsHide: true, env })
926
+ return l.status === 0 ? String(l.stdout || '').trim() : ''
927
+ } catch {
928
+ return ''
929
+ }
930
+ }
931
+
845
932
  /**
846
933
  * B3: Done Detection
847
934
  * Evaluates whether an issue task in the room is ready for landing.
@@ -872,42 +959,13 @@ export async function whenDone(opts = {}) {
872
959
  if (syncRunner) {
873
960
  hive = await syncRunner()
874
961
  } else {
875
- const parsed = parseLink(link)
876
- if (!parsed || !parsed.room) {
877
- return { ready: false, why: 'invalid room link', reasons: ['invalid room link'] }
962
+ const opened = await openDoneHive({ link, token })
963
+ if (opened.error) {
964
+ return { ready: false, why: opened.error, reasons: [opened.error] }
878
965
  }
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
- })
966
+ hive = opened.hive
967
+ tempDir = opened.tempDir
892
968
  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
969
  }
912
970
 
913
971
  // 1. Task check
@@ -975,6 +1033,11 @@ export async function whenDone(opts = {}) {
975
1033
  const why = dryRes ? dryRes.why : 'dry landing returned false'
976
1034
  return {
977
1035
  ready: false,
1036
+ // A DRY REFUSAL IS A REFUSAL, with its code. Once the dry run answers what the real one will
1037
+ // (0.14.3 P0-3), Q506 arrives HERE, before any real landing: dropping the code made it read as
1038
+ // "not ready yet", and the watcher stays quiet on that by design, so issue #9's silence came
1039
+ // back by the other door (Claude-cloud's review of P0-3).
1040
+ ...(dryRes && dryRes.code ? { refused: true, code: dryRes.code } : {}),
978
1041
  why: `dry landing check refused: ${why}`,
979
1042
  reasons: [why],
980
1043
  task,
@@ -988,6 +1051,9 @@ export async function whenDone(opts = {}) {
988
1051
  // 4. Review status check for every planned file
989
1052
  const reasons = []
990
1053
  const unreviewedFiles = []
1054
+ // Who reviewed each landing file, from the reviews bound to the exact content that lands: the PR
1055
+ // body names them (0.14.3 P1-10; the mock run's PR #7 did not say who reviewed it).
1056
+ const reviewed = []
991
1057
  let hasHardBlock = false
992
1058
 
993
1059
  for (const rel of plannedFiles) {
@@ -1004,6 +1070,9 @@ export async function whenDone(opts = {}) {
1004
1070
  unreviewedFiles.push(rel)
1005
1071
  if (!allowUnreviewed) reasons.push(`${rel} has not been reviewed`)
1006
1072
  }
1073
+ } else if (revStatus.verdict === 'pass') {
1074
+ const by = [...new Set((revStatus.reviews || []).filter((r) => r && r.verdict === 'pass' && r.by).map((r) => String(r.by)))]
1075
+ reviewed.push({ file: rel, by })
1007
1076
  } else if (revStatus.verdict === 'fail') {
1008
1077
  hasHardBlock = true
1009
1078
  reasons.push(`${rel} has an unresolved fail review`)
@@ -1038,6 +1107,7 @@ export async function whenDone(opts = {}) {
1038
1107
  return {
1039
1108
  ready: true,
1040
1109
  unreviewedFiles,
1110
+ reviewed,
1041
1111
  files: plannedFiles,
1042
1112
  deletions,
1043
1113
  task,
@@ -1062,6 +1132,19 @@ export async function whenDone(opts = {}) {
1062
1132
  * Implements plan fingerprint caching to avoid retry loops on failing tests.
1063
1133
  */
1064
1134
  export async function landIssue(opts = {}) {
1135
+ if (opts.syncRunner) return await landIssueOn(opts)
1136
+ const opened = await openDoneHive({ link: opts.link, token: opts.token })
1137
+ if (opened.error) return { ok: false, why: `Not ready to land: ${opened.error}`, reasons: [opened.error] }
1138
+ let res = null
1139
+ try {
1140
+ res = await landIssueOn({ ...opts, syncRunner: async () => opened.hive })
1141
+ return res
1142
+ } finally {
1143
+ await opened.close({ settleMs: res && res.toldRoom ? 1500 : 0 })
1144
+ }
1145
+ }
1146
+
1147
+ async function landIssueOn(opts = {}) {
1065
1148
  const {
1066
1149
  link,
1067
1150
  taskId,
@@ -1073,6 +1156,7 @@ export async function landIssue(opts = {}) {
1073
1156
  allowUnreviewed = false,
1074
1157
  state = {},
1075
1158
  token,
1159
+ headOf = branchHeadOf,
1076
1160
  } = opts
1077
1161
 
1078
1162
  // 1. Check readiness
@@ -1088,6 +1172,11 @@ export async function landIssue(opts = {}) {
1088
1172
  })
1089
1173
 
1090
1174
  if (!wd.ready) {
1175
+ // Refused at the dry run (a coded refusal, not "not done yet"): handed back WITH its code, so the
1176
+ // watcher says it once, on the issue and in the room, like a refusal of the real landing.
1177
+ if (wd.refused && wd.code) {
1178
+ return { ok: false, code: wd.code, why: wd.why, reasons: wd.reasons, refusedAt: 'dry' }
1179
+ }
1091
1180
  return {
1092
1181
  ok: false,
1093
1182
  ...(wd.blocked === 'unreviewed' ? { blocked: 'unreviewed', files: wd.unreviewedFiles || wd.files || [] } : {}),
@@ -1098,7 +1187,10 @@ export async function landIssue(opts = {}) {
1098
1187
 
1099
1188
  // 2. Plan fingerprint (Claude addition a: NO RETRY LOOP)
1100
1189
  const hive = wd.hive
1101
- const fingerprint = computePlanFingerprint(wd.plan, wd.files, hive, wd.hashes)
1190
+ // The branch head is part of it: the same room work against a branch that moved (or was fixed)
1191
+ // is a new landing, not a repeat of the failed one.
1192
+ const head = (() => { try { return headOf(root, branch) || '' } catch { return '' } })()
1193
+ const fingerprint = `${computePlanFingerprint(wd.plan, wd.files, hive, wd.hashes)}${head ? `@${head}` : ''}`
1102
1194
 
1103
1195
  if (state.lastFailedFingerprint === fingerprint) {
1104
1196
  return {
@@ -1128,6 +1220,13 @@ export async function landIssue(opts = {}) {
1128
1220
  if (wd.unreviewedFiles && wd.unreviewedFiles.length > 0) {
1129
1221
  summary += `\n\nNot reviewed by a second agent: ${wd.unreviewedFiles.join(', ')}`
1130
1222
  }
1223
+ if (wd.reviewed && wd.reviewed.length > 0) {
1224
+ // Names in code spans with any leading @ dropped: a room name like "@someone" must not ping a
1225
+ // GitHub user from a PR body.
1226
+ const who = (n) => `\`${String(n).replace(/^@+/, '').replace(/`/g, "'").slice(0, 60)}\``
1227
+ summary += '\n\nReviewed in the room (hive_review, bound to the exact content that lands):\n' +
1228
+ wd.reviewed.map((r) => `- ${r.file}: ${r.by.length ? r.by.map(who).join(', ') : 'a reviewer the room could not name'}`).join('\n')
1229
+ }
1131
1230
 
1132
1231
  const landFn = landRunner || landAndOpen
1133
1232
 
@@ -1159,6 +1258,7 @@ export async function landIssue(opts = {}) {
1159
1258
  why: landRes.why,
1160
1259
  fingerprint,
1161
1260
  state: nextState,
1261
+ toldRoom: Boolean(hive && (hive.assign || hive.say)),
1162
1262
  }
1163
1263
  }
1164
1264
 
@@ -1296,7 +1396,13 @@ export function collectPrFeedback(opts = {}) {
1296
1396
  )
1297
1397
  }
1298
1398
  if (issueNumber) {
1299
- streams.push(['ic', () => (gh && gh.comments ? gh.comments(issueNumber) : []), 'comment on the issue'])
1399
+ // A DELETED ISSUE answers 404 on its comments forever; 0.14.2 threw here on every tick, so the
1400
+ // watcher jammed with Q603 until restart while the PR was still open (0.14.3 P1). Gone means
1401
+ // no comments; any other failure (rate limit, 5xx) still throws and is retried next tick.
1402
+ streams.push(['ic', () => {
1403
+ if (!gh || !gh.comments) return []
1404
+ try { return gh.comments(issueNumber) } catch (e) { if (e && e.status === 404) return []; throw e }
1405
+ }, 'comment on the issue'])
1300
1406
  }
1301
1407
 
1302
1408
  const fresh = []