hivecode-mcp 0.14.8 → 0.14.9

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.
@@ -164,6 +164,47 @@ Notifications are optional: one Slack or Discord webhook URL. They are sent when
164
164
 
165
165
  Nothing is sent for each comment.
166
166
 
167
+ ## GitHub runs it: the Hivecode GitHub Action (agents all in the cloud)
168
+
169
+ Since 0.14.9. Nothing runs on anyone's computer and no agent needs a GitHub login. GitHub runs a
170
+ short job on each event and every 10 minutes, with the token it gives the job.
171
+
172
+ ### Setup, once per repository
173
+
174
+ 1. Make the room for a branch and push the branch, from any clone:
175
+ `npx -y hivecode-mcp@latest branch <branch>` then `git push origin <branch>`
176
+ 2. Add the room's link as a repository secret named `HIVE_ROOM`: Settings → Secrets and variables →
177
+ Actions → New repository secret.
178
+ 3. Add the workflow file. `npx -y hivecode-mcp@latest github action --workflow --branch <branch>`
179
+ prints it; save it as `.github/workflows/hivecode.yml`.
180
+ 4. The agents join the room. They can all run in the cloud.
181
+
182
+ ### What happens
183
+
184
+ | on GitHub | the job does |
185
+ |---|---|
186
+ | A person who can push labels an issue `hivecode` | Posts it into the room as one task for @all, quoted as data, and comments on the issue who is in the room. Another labelled issue waits, and is told so. |
187
+ | The room marks the task done, with every file reviewed by someone who did not write it | The next run lands it: the tests on the exact commit, a push to the branch, and the pull request (`<title> (#N)`, `Closes #N`). An agent can start that run at once (Actions → Hivecode → Run workflow); otherwise the 10-minute timer does. |
188
+ | A review comment, or a failed check on the pull request | One task for @all with the comments, quoted. Each comment is sent once. |
189
+ | The room fixes it | The next run pushes the fix to the same pull request. |
190
+ | The pull request is merged or closed | The room is told, the label comes off, and the next labelled issue comes in. |
191
+ | The issue is closed or unlabelled mid-work | The room is told to stop. Labelling it again sends it again, as a new attempt. |
192
+
193
+ ### No state file
194
+
195
+ A job starts on a fresh machine, so nothing is kept between runs. Which issue is in work is read from
196
+ Hivecode's marker comment on the issue; its task from the room; the last landing from a marker
197
+ comment; the review comments already sent from the room's tasks. Every step can run twice without
198
+ doing anything twice, and the workflow lets only one run go at a time.
199
+
200
+ ### Limits
201
+
202
+ - A pull request opened with the job's token does not start other workflows (a GitHub rule), so the
203
+ repository's own CI does not run on it until someone pushes to it or re-runs it. The tests Hivecode
204
+ runs before pushing are reported in the pull request.
205
+ - GitHub can run timers late, and turns them off in a public repository with no activity for 60 days.
206
+ - Private repositories spend Actions minutes: about one per run.
207
+
167
208
  ## No watcher: an agent in the room handles GitHub (`hive_issue`, `hive_pr`)
168
209
 
169
210
  Since 0.14.8 nothing has to be started or kept running. The agent whose machine holds the room's
@@ -72,6 +72,8 @@ This guide explains every error code (`Qxxx`) emitted by Hivecode CLI commands,
72
72
  | `Q610` | `hive-github.js` | Watcher fetching branches | Git remote error: could not list or fetch branches from origin. | Check network connection, git credentials, and remote repository existence. |
73
73
  | `Q611` | `hive-github.js` | Watcher creating issue branch | Branch diverged (commits on GitHub and local that are not in each other) or branch creation failed. | Reconcile branches manually and comment `/hivecode retry` on the issue. |
74
74
  | `Q612` | `hive-github.js` | Watcher updating checkpoint | Marker update failed during watcher run. | Check disk space and permissions in `.git/hivecode`. |
75
+ | `Q613` | `hive-gh-action.js` | The Hivecode GitHub Action (`github action`) | The job cannot start: the repository secret `HIVE_ROOM` is missing or is not a room link, `HIVE_BRANCH` is not set in the workflow, the step runs outside a checkout, or origin is not a GitHub repository. The room link is never printed. | Add the room's link as the secret `HIVE_ROOM` (Settings → Secrets and variables → Actions), set `HIVE_BRANCH` in the workflow, and keep `actions/checkout` with `fetch-depth: 0` before the step. `npx -y hivecode-mcp@latest github action --workflow --branch <name>` prints a working file. |
76
+ | `Q614` | `hive-gh-action.js` | The Hivecode GitHub Action | The branch named in `HIVE_BRANCH` is not on origin, so there is nothing to land onto. | Create the branch and its room once (`npx -y hivecode-mcp@latest branch <name>`), push the branch, then the next run works. |
75
77
  | `Q630` | `hive-slack.js`, `hive-slack-agent.js`, `hive-mcp-http.js` | Starting Slack bridge, agent, check, or HTTP MCP | `SLACK_BOT_TOKEN` is missing, rejected (`invalid_auth`), or revoked. | Copy `xoxb-…` Bot Token from Slack App settings (**OAuth & Permissions**) and set `SLACK_BOT_TOKEN`. |
76
78
  | `Q631` | `hive-slack.js` | Starting Slack bridge or `slack check` | Missing `--channel`, channel not found, or bot not invited. | Pass `--channel <id>` and run `/invite @Hivecode` in the target Slack channel. |
77
79
  | `Q632` | `hive-slack.js` | Starting `slack start` | Another Slack bridge with the same name is already running in the room. | Stop the other bridge before starting a new one, or pass a different `--name`. |
@@ -0,0 +1,382 @@
1
+ // hive-gh-action.js -- `github action`: the GitHub side of a room, run by GitHub Actions.
2
+ //
3
+ // WHY. Agents that all run in the cloud cannot hold a GitHub login, and a watcher on somebody's PC
4
+ // stops when the PC does. GitHub runs this instead: a short job on each event (an issue labelled,
5
+ // a review, a pull request closed) and on a timer, with the token GitHub gives the job. Nothing of
6
+ // ours runs anywhere between jobs.
7
+ //
8
+ // WHAT ONE RUN DOES. Whatever the event, it looks at GitHub and the room and does the one next step:
9
+ //
10
+ // an issue was closed or unlabelled -> tell the room to stop work on it
11
+ // a pull request on the branch was closed -> tell the room; take the label off its issue
12
+ // no issue is in work -> post the oldest labelled issue into the room
13
+ // an issue is in work and its task is done -> land it: tests, push, open or update its PR
14
+ // its pull request is open -> new review comments and failed checks to the room
15
+ //
16
+ // NO STATE FILE. A job starts on a fresh machine. What a watcher kept in .git/hivecode is read from
17
+ // where it already is: which issue is in work from Hivecode's marker comment on the issue, its task
18
+ // from the room, the last landing from a marker comment, the review comments already sent from the
19
+ // room's tasks. Every step can run twice without doing anything twice.
20
+ //
21
+ // THE ROOM LINK is a repository secret (HIVE_ROOM). It is never printed and never written to GitHub:
22
+ // anyone holding it can read the code in the room.
23
+ //
24
+ // Codes: Q613 setup (no room link, no branch, not a checkout), Q614 the branch is not on origin.
25
+
26
+ import fs from 'fs'
27
+ import path from 'path'
28
+ import { spawnSync } from 'child_process'
29
+ import { parseLink } from './sync.js'
30
+
31
+ export const LABEL = 'hivecode'
32
+ const MARK = (kind, fields = {}) => `<!-- hivecode:room kind=${kind}${Object.entries(fields).map(([k, v]) => ` ${k}=${v}`).join('')} -->`
33
+ const markOf = (body, kind) => {
34
+ const m = String(body || '').match(new RegExp(`<!-- hivecode:room kind=${kind}((?: [a-z]+=[^ ]*)*) -->`))
35
+ if (!m) return null
36
+ const out = {}
37
+ for (const p of m[1].trim().split(/\s+/).filter(Boolean)) { const i = p.indexOf('='); out[p.slice(0, i)] = p.slice(i + 1) }
38
+ return out
39
+ }
40
+ const oneLine = (s, n = 300) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n)
41
+ const PUSHERS = new Set(['admin', 'maintain', 'write'])
42
+ const ISSUE_TASK = (n) => new RegExp(`^Issue #${n}(?: \\(attempt \\d+\\))?:`)
43
+
44
+ /** The issue a pull request closes, from the "Closes #N" Hivecode writes first in its body. */
45
+ export function closesOf(body) {
46
+ const m = String(body || '').match(/(?:^|\n)\s*(?:closes|fixes|resolves) #(\d+)\b/i)
47
+ return m ? Number(m[1]) : 0
48
+ }
49
+
50
+ /** The room's tasks posted by Hivecode for issue n, newest first. */
51
+ export function issueTasks(tasks, n) {
52
+ return (tasks || []).filter((t) => t && t.by === 'GitHub' && ISSUE_TASK(n).test(String(t.text || '')))
53
+ .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
54
+ }
55
+
56
+ /** Review comments already sent to the room, as the `seen` collectPrFeedback takes: per stream, the
57
+ * highest comment id named in a "[rc:1,rv:2]" batch tag of a task Hivecode posted. Ids only grow. */
58
+ export function seenFromTasks(tasks) {
59
+ const seen = { ic: 0, pc: 0, rc: 0, rv: 0 }
60
+ for (const t of tasks || []) {
61
+ if (!t || t.by !== 'GitHub') continue
62
+ const tag = String(t.text || '').match(/\[((?:(?:ic|pc|rc|rv):\d+,?)+)\]\s*$/)
63
+ if (!tag) continue
64
+ for (const part of tag[1].split(',')) {
65
+ const [k, v] = part.split(':')
66
+ if (k in seen) seen[k] = Math.max(seen[k], Number(v) || 0)
67
+ }
68
+ }
69
+ return seen
70
+ }
71
+
72
+ function git(cwd, args) {
73
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
74
+ return { code: r.status === null ? 1 : r.status, out: String(r.stdout || '').trim(), err: String(r.stderr || '').trim() }
75
+ }
76
+
77
+ /** A passive client named GitHub that holds no files, synced (a relay waking from sleep can take a
78
+ * minute), or a throw: a run that could not read the room must not act as if the room were empty. */
79
+ async function openRoom(link) {
80
+ const os = await import('os')
81
+ const crypto = await import('crypto')
82
+ const { startSync } = await import('./sync.js')
83
+ const parsed = parseLink(link)
84
+ const dir = path.join(os.tmpdir(), `hive-action-${crypto.randomBytes(6).toString('hex')}`)
85
+ fs.mkdirSync(dir, { recursive: true })
86
+ const hive = startSync({ relay: parsed.relay, room: parsed.room, dir, name: 'GitHub', kind: 'ai', passive: true, syncFiles: false, token: parsed.token || '', log: () => { } })
87
+ const synced = await new Promise((res) => {
88
+ if (hive.provider && hive.provider.synced) return res(true)
89
+ const timer = setTimeout(() => res(false), 90000)
90
+ hive.provider && hive.provider.on('sync', (ok) => { if (ok) { clearTimeout(timer); res(true) } })
91
+ })
92
+ const close = async (settleMs = 0) => {
93
+ if (settleMs) await new Promise((r) => setTimeout(r, settleMs))
94
+ try { hive.stop() } catch { }
95
+ try { fs.rmSync(dir, { recursive: true, force: true }) } catch { }
96
+ }
97
+ if (!synced) { await close(); throw new Error('could not reach the room through its relay in 90 s; the next run tries again') }
98
+ return { hive, close }
99
+ }
100
+
101
+ /** The real things one run reaches: the room and the landing. `github` reaches the landing (tests). */
102
+ export async function roomDeps({ link, root, branch, github }) {
103
+ const RM = await import('./hive-room-mode.js')
104
+ const RW = await import('./hive-room-watcher.js')
105
+ return {
106
+ tasks: async () => { const r = await openRoom(link); try { return [...r.hive.doc.getMap('tasks').values()] } finally { await r.close() } },
107
+ assignFix: async (text) => { const r = await openRoom(link); try { r.hive.assign('all', text) } finally { await r.close(1500) } },
108
+ say: (text) => RW.roomSay({ link, text }),
109
+ postIssue: (issue) => RM.postIssue({ link, issue, waysOfWorking: RW.WAYS_OF_WORKING, branch }),
110
+ landIssue: (o) => RM.landIssue({ link, root, branch, ...(github ? { github } : {}), ...o }),
111
+ prFeedback: (o) => RM.postPrFeedback({ link, root, branch, quietMs: 0, ...o }),
112
+ bind: async () => {
113
+ const HB = await import('./hive-branch.js')
114
+ if (HB.readBinding(root, branch)) return
115
+ await HB.bindBranch({ cwd: root, branch, join: link, you: 'GitHub' })
116
+ },
117
+ }
118
+ }
119
+
120
+ /**
121
+ * One run. `gh` is a makeGitHub client (the job's token, through `gh`); `deps` reaches the room.
122
+ * Returns { did: [what it did, for the log] }. A refusal is a result; a throw is a failed job, which
123
+ * GitHub shows in red and the next run retries.
124
+ */
125
+ export async function runAction({ gh, deps, eventName = '', event = {}, branch, label = LABEL, base = '', log = () => { }, notify = async () => false }) {
126
+ const did = []
127
+ const note = (s) => { did.push(s); log(s) }
128
+ // What a person follows in Slack or Discord (HIVE_NOTIFY_WEBHOOK): the issue's life, never the room link.
129
+ const where = gh.owner && gh.repo ? `${gh.owner}/${gh.repo}` : 'the repository'
130
+ const tell = async (s) => { try { await notify(`Hivecode, ${where}: ${s}`) } catch { } }
131
+ const cache = new Map()
132
+ const comment = (n, body) => { cache.delete(n); return gh.comment(n, body) }
133
+ // Hivecode's marks count only from the latest time the label went on: labelling an issue again
134
+ // after its pull request was closed, or after it was stopped, sends it to the room again.
135
+ const labelAt = (n) => {
136
+ const evs = (gh.issueEvents(n) || []).filter((e) => e && e.event === 'labeled' && e.label && String(e.label.name).toLowerCase() === label.toLowerCase())
137
+ const last = evs[evs.length - 1]
138
+ return { at: last ? Date.parse(last.created_at) || 0 : 0, by: last && last.actor ? last.actor.login : '' }
139
+ }
140
+ const commentsOf = (n) => {
141
+ if (!cache.has(n)) {
142
+ const since = labelAt(n)
143
+ const all = gh.comments(n) || []
144
+ cache.set(n, { all, now: all.filter((c) => (Date.parse(c.created_at) || 0) >= since.at), by: since.by })
145
+ }
146
+ return cache.get(n).now
147
+ }
148
+ const attemptOf = (n) => { commentsOf(n); return cache.get(n).all.filter((c) => markOf(c.body, 'sent')).length + 1 }
149
+ const labelled = () => (gh.labeledIssues(label) || []).filter((i) => i && !i.pull_request)
150
+ .sort((a, b) => a.number - b.number)
151
+ const canPush = (login) => { try { return !!login && PUSHERS.has(gh.permission(login)) } catch { return false } }
152
+ // Who put the label on: a label from someone who cannot push hands nothing to the agents.
153
+ const labeller = (n) => { commentsOf(n); return cache.get(n).by }
154
+ const said = (n, kind) => commentsOf(n).some((c) => markOf(c.body, kind))
155
+
156
+ // ── 1. what the event itself says ─────────────────────────────────────────────────────────────
157
+ const issueN = event.issue && !event.issue.pull_request ? Number(event.issue.number) : 0
158
+ if (eventName === 'issues' && issueN && (event.action === 'closed' || (event.action === 'unlabeled' && event.label && String(event.label.name).toLowerCase() === label.toLowerCase()))) {
159
+ if (said(issueN, 'sent') && !said(issueN, 'stopped') && !said(issueN, 'prclosed')) {
160
+ await deps.say(`@all issue #${issueN} was ${event.action === 'closed' ? 'closed' : 'unlabelled'} on GitHub: stop work on it. Nothing more from it lands.`)
161
+ comment(issueN, `Hivecode told the room to stop work on this issue.\n\n${MARK('stopped')}`)
162
+ note(`#${issueN}: room told to stop (${event.action})`)
163
+ await tell(`issue #${issueN} was ${event.action === 'closed' ? 'closed' : 'unlabelled'}; the room was told to stop.`)
164
+ }
165
+ }
166
+
167
+ // ── 2. pull requests on the branch that were closed ───────────────────────────────────────────
168
+ const pulls = (gh.pullsFor(branch) || []).filter((p) => p && (!p.head || !p.head.ref || p.head.ref === branch))
169
+ for (const p of pulls.filter((x) => x.state === 'closed')) {
170
+ const n = closesOf(p.body)
171
+ if (!n) continue
172
+ let issue = null
173
+ try { issue = gh.issue(n) } catch { continue }
174
+ const labels = (issue.labels || []).map((l) => String(typeof l === 'string' ? l : l.name).toLowerCase())
175
+ // Only an issue this room worked on, and only once: the label is off when it was dealt with.
176
+ if (!labels.includes(label.toLowerCase())) continue
177
+ if (!said(n, 'sent')) continue
178
+ if (said(n, 'prclosed')) { try { gh.removeLabel(n, label) } catch { } continue }
179
+ const merged = !!p.merged_at
180
+ await deps.say(`@all PR #${p.number} for issue #${n} was ${merged ? 'merged' : 'closed without merging'} on GitHub. ${merged ? 'That issue is finished.' : 'Nothing more lands for it.'} The next issue comes in now, if there is one.`)
181
+ comment(n, `${merged ? `Merged in #${p.number}.` : `#${p.number} was closed without merging; label this issue \`${label}\` again to send it to the room again.`}\n\n${MARK('prclosed', { pr: p.number })}`)
182
+ gh.removeLabel(n, label)
183
+ note(`PR #${p.number} (issue #${n}) ${merged ? 'merged' : 'closed'}: room told`)
184
+ await tell(`PR #${p.number} for issue #${n} was ${merged ? 'merged' : 'closed without merging'}.`)
185
+ }
186
+
187
+ // ── 3. the issue in work, if any: open, labelled, sent to the room, its room not told to stop ─
188
+ const open = labelled()
189
+ const inWork = open.filter((i) => { const cs = commentsOf(i.number); return cs.some((c) => markOf(c.body, 'sent')) && !cs.some((c) => markOf(c.body, 'stopped') || markOf(c.body, 'prclosed')) })
190
+ let active = inWork[0] || null
191
+
192
+ // ── 4. nothing in work: post the oldest labelled issue ────────────────────────────────────────
193
+ if (!active) {
194
+ for (const i of open) {
195
+ if (inWork.includes(i)) continue
196
+ const cs = commentsOf(i.number)
197
+ if (cs.some((c) => markOf(c.body, 'stopped') || markOf(c.body, 'prclosed'))) continue
198
+ const who = labeller(i.number)
199
+ if (!canPush(who)) {
200
+ if (!cs.some((c) => markOf(c.body, 'notpusher'))) comment(i.number, `Hivecode does not send this to the room: the \`${label}\` label was added by ${who ? `@${who.replace(/[^A-Za-z0-9-]/g, '')}` : 'someone'}, who cannot push to this repository. Someone who can push adds it.\n\n${MARK('notpusher')}`)
201
+ continue
202
+ }
203
+ const p = await deps.postIssue({ number: i.number, title: i.title, body: i.body || '', author: (i.user && i.user.login) || 'unknown', labeller: who, attempt: attemptOf(i.number) })
204
+ if (!p || !p.ok) { note(`#${i.number}: could not post to the room (${(p && p.code) || 'no answer'}); the next run tries again`); return { did } }
205
+ comment(i.number, `${p.comment || 'Sent to the room.'}\n\n${MARK('sent', { task: p.taskId || '' })}`)
206
+ note(`#${i.number}: sent to the room as task ${p.taskId}`)
207
+ await tell(`issue #${i.number} "${oneLine(i.title, 120)}" is in the room. ${oneLine(p.comment || '', 200)}`)
208
+ return { did }
209
+ }
210
+ if (!open.length) note('no issue is labelled; nothing to do')
211
+ return { did }
212
+ }
213
+
214
+ // Others wait, and are told so once.
215
+ for (const i of open) {
216
+ if (i === active || inWork.includes(i)) continue
217
+ const cs = commentsOf(i.number)
218
+ if (cs.some((c) => markOf(c.body, 'sent') || markOf(c.body, 'queued') || markOf(c.body, 'stopped') || markOf(c.body, 'prclosed'))) continue
219
+ comment(i.number, `Queued: the room works on one issue at a time. This comes in when the pull request for #${active.number} is merged or closed.\n\n${MARK('queued')}`)
220
+ note(`#${i.number}: queued behind #${active.number}`)
221
+ }
222
+
223
+ // ── 5. the issue in work: land it when its task is done ───────────────────────────────────────
224
+ const n = active.number
225
+ const tasks = await deps.tasks()
226
+ const task = issueTasks(tasks, n)[0]
227
+ if (!task) { note(`#${n}: its task is not in the room (the relay lost the room?); nothing to land`); return { did } }
228
+ const openPr = pulls.find((p) => p.state === 'open' && closesOf(p.body) === n) || null
229
+ const cs = commentsOf(n)
230
+ const lastLand = cs.map((c) => markOf(c.body, 'land')).filter(Boolean).pop() || null
231
+ if (task.status === 'done') {
232
+ await deps.bind()
233
+ const r = await deps.landIssue({
234
+ taskId: task.id,
235
+ issue: { number: n, title: active.title },
236
+ // The same plan, branch and room as the last landing, ok or not, is not run again.
237
+ state: { lastFailedFingerprint: lastLand ? lastLand.fp : '' },
238
+ by: 'GitHub Actions',
239
+ closes: true,
240
+ skipEmpty: !!openPr,
241
+ ...(base ? { base } : {}),
242
+ })
243
+ if (r && r.skipped) note(`#${n}: ${r.nothing ? 'nothing new to land' : 'nothing changed since the last landing'}`)
244
+ else if (r && r.ok && r.pr && r.pr.number) {
245
+ const made = r.pr.created || !openPr
246
+ comment(n, `${made ? `Pull request #${r.pr.number} is open for review` : `#${r.pr.number} was updated`}${r.tests && r.tests.ran ? ` (tests: \`${oneLine(r.tests.command, 80)}\` passed)` : ' (tests: not run)'}.\n\n${MARK('land', { fp: r.fingerprint || '', ok: 1, pr: r.pr.number })}`)
247
+ await deps.say(made ? `@all issue #${n} is up for review: PR #${r.pr.number} ${r.pr.url || ''}`.trim()
248
+ : `@all PR #${r.pr.number} for issue #${n} was updated${r.pr.newCommits ? ` with ${r.pr.newCommits} new commit(s)` : ''}.`)
249
+ note(`#${n}: ${made ? 'opened' : 'updated'} PR #${r.pr.number}`)
250
+ await tell(made ? `issue #${n} is up for review: ${r.pr.url || `PR #${r.pr.number}`}` : `PR #${r.pr.number} for issue #${n} was updated with the room's fixes.`)
251
+ } else if (r && r.code) {
252
+ // A refusal with a code is said once per landing (its fingerprint), on the issue and in the room.
253
+ if (!lastLand || lastLand.fp !== (r.fingerprint || '') || lastLand.code !== r.code) {
254
+ comment(n, `Hivecode could not land this (${r.code}): ${oneLine(r.why, 600)}\nNothing was pushed. It is tried again when the room's files or the branch change.\n\n${MARK('land', { fp: r.fingerprint || '', code: r.code })}`)
255
+ await tell(`issue #${n}: the landing was refused (${r.code}). Nothing was pushed; the room was told.`)
256
+ if (r.code !== 'Q576') await deps.say(`@all issue #${n} is done, but its landing was refused (${r.code}): ${oneLine(r.why, 400)} Nothing was pushed.`)
257
+ }
258
+ note(`#${n}: landing refused (${r.code})`)
259
+ } else note(`#${n}: not ready to land: ${oneLine(r && r.why, 200)}`)
260
+ } else note(`#${n}: task ${task.id} is ${task.status}; waiting for the room`)
261
+
262
+ // ── 6. its open pull request: review comments and failed checks back to the room ─────────────
263
+ if (openPr) {
264
+ const fb = await deps.prFeedback({ gh, issueNumber: n, prNumber: openPr.number, state: { seen: seenFromTasks(tasks) } })
265
+ if (fb && fb.ok && fb.items && fb.items.length && !fb.already) { note(`PR #${openPr.number}: ${fb.items.length} review comment(s) sent to the room`); await tell(`${fb.items.length} review comment(s) on PR #${openPr.number} went to the room.`) }
266
+ const sha = openPr.head && openPr.head.sha
267
+ if (sha) {
268
+ const GH = await import('./hive-github.js')
269
+ // The job's own run is not a check on the pull request; the rest are.
270
+ const v = GH.ciVerdict(gh.checkRuns(sha), gh.statuses(sha))
271
+ const short = String(sha).slice(0, 7)
272
+ const marker = `Fix the failing checks on PR #${openPr.number} at ${short}`
273
+ if (v.state === 'failure' && !tasks.some((t) => t && t.by === 'GitHub' && String(t.text || '').startsWith(marker))) {
274
+ await deps.assignFix(`${marker}: ${v.failed.slice(0, 8).map((i) => oneLine(i.name, 60)).join(', ')}`)
275
+ await deps.say(`@all the checks failed on PR #${openPr.number} at ${short}: ${v.failed.slice(0, 8).map((i) => `${oneLine(i.name, 60)}${i.url ? ` ${i.url}` : ''}`).join('; ')}. Fix them in the room; the fix goes to the same pull request.`)
276
+ note(`PR #${openPr.number}: failed checks sent to the room`)
277
+ await tell(`checks failed on PR #${openPr.number}; the room was told.`)
278
+ }
279
+ }
280
+ }
281
+ return { did }
282
+ }
283
+
284
+ // ── the workflow file a repository adds ──────────────────────────────────────────────────────────
285
+ export function workflowFile({ branch = '<branch>', version = 'latest' } = {}) {
286
+ return `# Hivecode: GitHub issues go to the agents in a room, and their work comes back as a pull request.
287
+ # Needs one repository secret, HIVE_ROOM: the room's link (Settings -> Secrets and variables -> Actions).
288
+ name: Hivecode
289
+
290
+ on:
291
+ issues:
292
+ types: [opened, labeled, unlabeled, closed, reopened]
293
+ issue_comment:
294
+ types: [created]
295
+ pull_request_review:
296
+ types: [submitted]
297
+ pull_request_review_comment:
298
+ types: [created]
299
+ pull_request:
300
+ types: [closed]
301
+ workflow_dispatch: # an agent starts this when the room's task is done
302
+ schedule:
303
+ - cron: '*/10 * * * *'
304
+
305
+ permissions:
306
+ contents: write
307
+ issues: write
308
+ pull-requests: write
309
+ checks: read
310
+
311
+ # One run at a time: two runs never post the same issue or land twice.
312
+ concurrency:
313
+ group: hivecode
314
+ cancel-in-progress: false
315
+
316
+ jobs:
317
+ room:
318
+ runs-on: ubuntu-latest
319
+ timeout-minutes: 25
320
+ steps:
321
+ - uses: actions/checkout@v4
322
+ with:
323
+ fetch-depth: 0
324
+ - uses: actions/setup-node@v4
325
+ with:
326
+ node-version: 22
327
+ - run: npx -y hivecode-mcp@${version} github action
328
+ env:
329
+ HIVE_ROOM: \${{ secrets.HIVE_ROOM }}
330
+ HIVE_BRANCH: ${branch}
331
+ # optional: a Slack or Discord incoming webhook, to follow each issue there
332
+ HIVE_NOTIFY_WEBHOOK: \${{ secrets.HIVE_NOTIFY_WEBHOOK }}
333
+ GH_TOKEN: \${{ github.token }}
334
+ `
335
+ }
336
+
337
+ // ── the command ──────────────────────────────────────────────────────────────────────────────────
338
+ export async function main(argv = [], { cwd = process.cwd(), env = process.env, log = console.log, logErr = console.error, call } = {}) {
339
+ if (argv.includes('--workflow')) {
340
+ const bi = argv.indexOf('--branch')
341
+ log(workflowFile({ branch: bi !== -1 && argv[bi + 1] ? argv[bi + 1] : '<branch>' }))
342
+ return 0
343
+ }
344
+ const link = String(env.HIVE_ROOM || '').trim()
345
+ const branch = String(env.HIVE_BRANCH || '').trim()
346
+ const label = String(env.HIVE_LABEL || '').trim() || LABEL
347
+ if (!link || !parseLink(link).room) { logErr('Q613 HIVE_ROOM is not set to a room link. Add the room\'s link as the repository secret HIVE_ROOM (Settings -> Secrets and variables -> Actions), and pass it to this step as env.'); return 2 }
348
+ if (!branch) { logErr('Q613 HIVE_BRANCH is not set: name the branch the room is bound to, in the workflow\'s env.'); return 2 }
349
+ const top = git(cwd, ['rev-parse', '--show-toplevel'])
350
+ if (top.code !== 0) { logErr(`Q613 ${cwd} is not a git checkout. The workflow runs actions/checkout (with fetch-depth: 0) first.`); return 2 }
351
+ const root = path.resolve(top.out)
352
+ // The branch, as a local branch: the landing works on refs/heads/<branch>.
353
+ if (git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]).code !== 0) {
354
+ git(root, ['fetch', '--quiet', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`])
355
+ if (git(root, ['branch', '--no-track', branch, `refs/remotes/origin/${branch}`]).code !== 0) { logErr(`Q614 there is no branch ${branch} on origin. Create it and its room once: npx -y hivecode-mcp@latest branch ${branch}, then push the branch.`); return 2 }
356
+ }
357
+ // Commits need a committer; a fresh runner has none.
358
+ if (git(root, ['config', 'user.email']).code !== 0) {
359
+ git(root, ['config', 'user.name', 'github-actions[bot]'])
360
+ git(root, ['config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com'])
361
+ }
362
+ const GH = await import('./hive-github.js')
363
+ const id = GH.parseRepo(git(root, ['remote', 'get-url', 'origin']).out) ||
364
+ (env.GITHUB_REPOSITORY ? { owner: env.GITHUB_REPOSITORY.split('/')[0], repo: env.GITHUB_REPOSITORY.split('/')[1] } : null)
365
+ if (!id) { logErr('Q613 origin is not a GitHub repository.'); return 2 }
366
+ const gh = GH.makeGitHub({ call: call || GH.ghCall(), owner: id.owner, repo: id.repo })
367
+ let event = {}
368
+ try { if (env.GITHUB_EVENT_PATH) event = JSON.parse(fs.readFileSync(env.GITHUB_EVENT_PATH, 'utf8')) } catch { }
369
+ let base = String(env.HIVE_BASE || '').trim()
370
+ if (!base) { try { base = gh.repoInfo().default_branch || '' } catch { } }
371
+ const deps = await roomDeps({ link, root, branch })
372
+ try {
373
+ const W = await import('./hive-webhook.js')
374
+ const notify = W.makeNotifier(String(env.HIVE_NOTIFY_WEBHOOK || '').trim(), { log })
375
+ const r = await runAction({ gh, deps, eventName: env.GITHUB_EVENT_NAME || '', event, branch, label, base, log, notify })
376
+ if (!r.did.length) log('nothing to do')
377
+ return 0
378
+ } catch (e) {
379
+ logErr(`hivecode: ${GH.scrub ? GH.scrub(e.message) : e.message}`)
380
+ return 1
381
+ }
382
+ }
package/hive-github.js CHANGED
@@ -1640,6 +1640,8 @@ const USAGE = `usage:
1640
1640
  npx -y hivecode-mcp@latest github start --room "<room link>" [--fallback-agent "<command>"] [--ack-minutes 10]
1641
1641
  npx -y hivecode-mcp@latest github start --branch <name> [--fallback-agent "<command>"] [--ack-minutes 10]
1642
1642
  npx -y hivecode-mcp@latest github status
1643
+ npx -y hivecode-mcp@latest github action one run inside GitHub Actions (env HIVE_ROOM, HIVE_BRANCH)
1644
+ npx -y hivecode-mcp@latest github action --workflow --branch <name> print the workflow file to add
1643
1645
 
1644
1646
  --room: hand each labelled issue to the agents ALREADY in that room (a room bound to a branch of
1645
1647
  this repo: npx -y hivecode-mcp@latest branch <name>). They split it, build it and review each other;
@@ -1684,6 +1686,8 @@ export async function main(argv, { cwd: here = process.cwd(), log = console.log,
1684
1686
  const ri = rest.indexOf('--repo')
1685
1687
  const cwd = ri !== -1 && rest[ri + 1] ? path.resolve(rest[ri + 1]) : here
1686
1688
  if (ri !== -1) rest.splice(ri, 2)
1689
+ // `github action`: one run inside GitHub Actions (hive-gh-action.js). No watcher, no state file.
1690
+ if (sub === 'action') { const A = await import('./hive-gh-action.js'); return A.main(rest, { cwd, log, logErr, call }) }
1687
1691
  if (sub !== 'start' && sub !== 'status') { logErr(USAGE); return EXIT_FIX }
1688
1692
  const top = git(cwd, ['rev-parse', '--show-toplevel'])
1689
1693
  if (top.code !== 0) { logErr(`Q601 ${cwd} is not inside a git repository. Run this in your clone of the repo.`); return EXIT_FIX }
package/hive-room-mode.js CHANGED
@@ -1190,6 +1190,10 @@ async function landIssueOn(opts = {}) {
1190
1190
  base,
1191
1191
  allowWorkflowChanges,
1192
1192
  only,
1193
+ github,
1194
+ // The GitHub Action (hive-gh-action.js) passes this while the issue's PR is open: a run with
1195
+ // nothing new to land does nothing, instead of running the tests again to update nothing.
1196
+ skipEmpty = false,
1193
1197
  } = opts
1194
1198
 
1195
1199
  // 1. Check readiness
@@ -1218,6 +1222,10 @@ async function landIssueOn(opts = {}) {
1218
1222
  }
1219
1223
  }
1220
1224
 
1225
+ if (skipEmpty && !(wd.files && wd.files.length) && !(wd.deletions && wd.deletions.length)) {
1226
+ return { ok: false, skipped: true, nothing: true, why: 'nothing new to land: the branch already holds the room\'s work', state }
1227
+ }
1228
+
1221
1229
  // 2. Plan fingerprint (Claude addition a: NO RETRY LOOP)
1222
1230
  const hive = wd.hive
1223
1231
  // The branch head is part of it: the same room work against a branch that moved (or was fixed)
@@ -1276,6 +1284,7 @@ async function landIssueOn(opts = {}) {
1276
1284
  ...(base ? { base } : {}),
1277
1285
  ...(allowWorkflowChanges === true ? { allowWorkflowChanges: true } : {}),
1278
1286
  ...(only ? { only } : {}),
1287
+ ...(github ? { github } : {}),
1279
1288
  })
1280
1289
 
1281
1290
  if (!landRes.ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hivecode-mcp",
3
- "version": "0.14.8",
3
+ "version": "0.14.9",
4
4
  "description": "MCP server that lets any AI coding agent join a Hivecode room — humans and multiple AI agents edit one live codebase with folder-scoped permissions and approval gates.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,6 +38,7 @@
38
38
  "hive-pr.js",
39
39
  "hive-branch.js",
40
40
  "hive-land.js",
41
+ "hive-gh-action.js",
41
42
  "hive-issue.js",
42
43
  "hive-repos.js",
43
44
  "hive-room-mode.js",