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_RULES.md +41 -98
- package/docs/TROUBLESHOOTING.md +4 -0
- package/hive-await.js +57 -21
- package/hive-branch.js +68 -2
- package/hive-gate.js +116 -2
- package/hive-instructions.js +110 -61
- package/hive-land.js +15 -1
- package/hive-mailbox.js +74 -7
- package/hive-mcp.js +258 -47
- package/hive-mux.js +4 -1
- package/hive-notify.js +21 -5
- package/hive-pr.js +42 -5
- package/hive-receipts.js +56 -5
- package/hive-room-mode.js +143 -37
- package/hive-room-watcher.js +95 -16
- package/hive-seen.js +135 -0
- package/hive-slack.js +62 -4
- package/hive-stop-hook.js +33 -18
- package/hive-transport.js +85 -11
- package/icr.js +7 -2
- package/lang-js.js +54 -10
- package/package.json +13 -4
- package/sync.js +419 -225
- package/docs/HIVE_RULES.md +0 -41
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
|
-
|
|
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
|
-
|
|
146
|
-
if (
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
876
|
-
if (
|
|
877
|
-
return { ready: false, why:
|
|
962
|
+
const opened = await openDoneHive({ link, token })
|
|
963
|
+
if (opened.error) {
|
|
964
|
+
return { ready: false, why: opened.error, reasons: [opened.error] }
|
|
878
965
|
}
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = []
|