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.
- package/docs/TROUBLESHOOTING.md +1 -0
- package/hive-branch.js +68 -2
- package/hive-land.js +15 -1
- package/hive-mcp.js +59 -6
- package/hive-mux.js +4 -1
- package/hive-pr.js +42 -5
- package/hive-room-mode.js +130 -37
- package/hive-room-watcher.js +65 -4
- package/hive-slack.js +35 -2
- package/hive-transport.js +46 -7
- package/icr.js +7 -2
- package/lang-js.js +54 -10
- package/package.json +11 -3
- package/sync.js +265 -49
package/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
1783
|
-
|
|
1784
|
-
|
|
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': {
|
|
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
|
-
|
|
2082
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
876
|
-
if (
|
|
877
|
-
return { ready: false, why:
|
|
949
|
+
const opened = await openDoneHive({ link, token })
|
|
950
|
+
if (opened.error) {
|
|
951
|
+
return { ready: false, why: opened.error, reasons: [opened.error] }
|
|
878
952
|
}
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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 = []
|