hivecode-mcp 0.12.4 → 0.13.1
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 +11 -40
- package/README.md +421 -346
- package/docs/ALWAYS_ON.md +310 -0
- package/docs/GITHUB_WORKFLOW.md +394 -0
- package/docs/MCP.md +157 -118
- package/docs/QUICKSTART.md +266 -0
- package/docs/SELF_HOSTING.md +2 -2
- package/docs/SLACK_SETUP.md +450 -0
- package/docs/TROUBLESHOOTING.md +105 -0
- package/hive-authors.js +44 -0
- package/hive-await.js +35 -10
- package/hive-branch.js +101 -150
- package/hive-github.js +1944 -0
- package/hive-land.js +691 -0
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +5 -2
- package/hive-mcp-http.js +580 -0
- package/hive-mcp.js +173 -29
- package/hive-notify.js +233 -0
- package/hive-pr.js +205 -29
- package/hive-relay-health.js +230 -230
- package/hive-repos.js +430 -0
- package/hive-room-key.js +209 -0
- package/hive-room-mode.js +1668 -0
- package/hive-room-watcher.js +561 -0
- package/hive-session.js +114 -0
- package/hive-setup.js +14 -4
- package/hive-slack-agent.js +396 -0
- package/hive-slack.js +1809 -0
- package/hive-stop-hook.js +8 -5
- package/hive-time.js +71 -0
- package/hive-webhook.js +54 -0
- package/icr.js +851 -851
- package/limits.js +3 -3
- package/package.json +190 -172
- package/slack-app-manifest-agent.json +61 -0
- package/slack-app-manifest-inbound.json +36 -0
- package/slack-app-manifest.json +27 -0
- package/sync.js +4992 -4691
package/hive-land.js
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
// hive-land -- the ONE way a room's work leaves this machine: land, check, push, open the PR.
|
|
2
|
+
//
|
|
3
|
+
// Three callers, one path, so a check added here protects all of them:
|
|
4
|
+
// - `npx -y hivecode-mcp@latest pr <branch>` a developer, in their own clone (hive-branch.js main)
|
|
5
|
+
// - the hive_pr tool an agent on the repo machine, for its person
|
|
6
|
+
// - the GitHub watcher hive-github.js (through landBranch, for now)
|
|
7
|
+
//
|
|
8
|
+
// THE ORDER IS THE SAFETY. Nothing moves until every check has passed on the exact commit that
|
|
9
|
+
// would move:
|
|
10
|
+
// lock -> claims -> remote -> land in a temporary worktree -> claims again -> workflows -> secrets
|
|
11
|
+
// -> tests on that tip -> move the branch (compare-and-swap) -> push that exact sha -> open or update
|
|
12
|
+
// the PR -> unlock
|
|
13
|
+
//
|
|
14
|
+
// Each step is here because skipping it hurt somebody, or provably would (tests/land-test.mjs, red on
|
|
15
|
+
// the code before this file, 5 Nov 2026):
|
|
16
|
+
// - an EMPTY ROOM (the relay restarted) landed "Room: delete 9 files": a branch with no files left
|
|
17
|
+
// - a landing went through while an agent still held a claim: half a change on the branch
|
|
18
|
+
// - `pr` ran no tests, so failing work landed, and nothing ever said the work was untested
|
|
19
|
+
// - GitHub was ahead: the branch moved locally, THEN the push failed
|
|
20
|
+
//
|
|
21
|
+
// Error codes Q575-Q579 are this module's (Q560-Q574 stay hive-branch.js's, and pass through):
|
|
22
|
+
// Q575 a claim is still held (or appeared during the landing)
|
|
23
|
+
// Q576 the tests failed, ran past their time limit, or could not start
|
|
24
|
+
// Q577 the room is empty, or the landing would delete most of the branch
|
|
25
|
+
// Q578 a workflow file or a key in the change; origin ahead or unreachable; GitHub not logged in
|
|
26
|
+
// Q579 another landing of this branch is running
|
|
27
|
+
|
|
28
|
+
import fs from 'fs'
|
|
29
|
+
import os from 'os'
|
|
30
|
+
import path from 'path'
|
|
31
|
+
import { spawn, spawnSync } from 'child_process'
|
|
32
|
+
import { fileURLToPath } from 'url'
|
|
33
|
+
import * as HB from './hive-branch.js'
|
|
34
|
+
import { startSync, parseLink } from './sync.js'
|
|
35
|
+
import { findSecrets } from './hive-secrets.js'
|
|
36
|
+
import { makeCoordinator } from './hive-coord.js'
|
|
37
|
+
|
|
38
|
+
const HIVE_PR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'hive-pr.js')
|
|
39
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
40
|
+
const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
41
|
+
const git = (cwd, args, o) => HB.gitIn(cwd, args, o)
|
|
42
|
+
// GIT THAT TALKS TO THE NETWORK NEVER WAITS ON A PROMPT NOBODY CAN SEE. Run from an agent's tool
|
|
43
|
+
// call or a background watcher, a credential prompt (git's own, or Git Credential Manager's window)
|
|
44
|
+
// would hang the landing forever with the lock held. No prompt, and a time limit.
|
|
45
|
+
function gitNet(cwd, args, timeoutMs = Number(process.env.HIVE_GIT_NET_TIMEOUT_MS) || 120000) {
|
|
46
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' } })
|
|
47
|
+
const timedOut = r.error && r.error.code === 'ETIMEDOUT'
|
|
48
|
+
return { code: r.status === null ? 1 : r.status, out: (r.stdout || '').trim(), err: timedOut ? `no answer from the remote in ${Math.round(timeoutMs / 1000)} s` : ((r.stderr || '') + (r.error ? ` ${r.error.message}` : '')).trim() }
|
|
49
|
+
}
|
|
50
|
+
const short = (sha) => String(sha || '').slice(0, 7)
|
|
51
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' } }
|
|
52
|
+
const tailLines = (s, n) => String(s || '').replace(/\s+$/, '').split(/\r?\n/).slice(-n).join('\n')
|
|
53
|
+
// Hivecode's generated files in a room folder: never project work, never an "orphan".
|
|
54
|
+
const GENERATED = /^HIVE_[A-Z_]+\.md$/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* landAndOpen(opts) -> Promise<Result>
|
|
58
|
+
*
|
|
59
|
+
* opts
|
|
60
|
+
* cwd string any folder inside the repo (required)
|
|
61
|
+
* branch string the branch bound to the room (required)
|
|
62
|
+
* dry boolean DEFAULT TRUE: plan only. Nothing moves, no tests run, nothing is pushed.
|
|
63
|
+
* push boolean push the landed tip to origin. Never forced.
|
|
64
|
+
* pr boolean open the PR, or update the one already open (implies push)
|
|
65
|
+
* base string the branch the PR goes into (default: the binding's `from`)
|
|
66
|
+
* test string shell command run in a clean checkout of the EXACT tip that would land.
|
|
67
|
+
* '' = no tests: the landing still happens, and the result and the PR body
|
|
68
|
+
* say "tests: NOT RUN" in words, so untested never looks like tested.
|
|
69
|
+
* testMinutes number time limit for the tests (default 20)
|
|
70
|
+
* title string PR title (default "Hivecode: <branch>")
|
|
71
|
+
* summary string what was done, in the caller's words; goes in the PR body
|
|
72
|
+
* by string who asked, e.g. "Claude for Ganesh"; shown in the lock and the PR body
|
|
73
|
+
* allowWorkflowChanges boolean default false
|
|
74
|
+
* allowMassDelete boolean default false: a landing that deletes most of the branch is refused
|
|
75
|
+
* force string a reason; overrides hive-pr's staleness refusal, recorded in the PR body
|
|
76
|
+
* only string comma-separated paths: land only these
|
|
77
|
+
* claims boolean default true: refuse while anyone holds a claim
|
|
78
|
+
* checks boolean default true: the workflow and key checks (the watcher runs its own for now)
|
|
79
|
+
* github object a GitHub client (hive-github.js makeGitHub shape); default: through `gh`
|
|
80
|
+
* log, logErr (line) => void
|
|
81
|
+
*
|
|
82
|
+
* Result. An EXPECTED refusal is a result, never a throw; a throw means a bug.
|
|
83
|
+
* ok boolean
|
|
84
|
+
* code '' or the refusal code ('Q575' ...; also Q5xx passed up from hive-branch / hive-pr)
|
|
85
|
+
* why for a person: what happened and what to do next
|
|
86
|
+
* dry boolean
|
|
87
|
+
* plan [{ agent, files: [rel], intents: [string] }] what lands, one entry per commit
|
|
88
|
+
* hashes { rel: contentHash } the room's content of each planned file, as the mirror read it
|
|
89
|
+
* (from hive-pr --plan-json; {} with an older hive-pr)
|
|
90
|
+
* deletions [rel] files the room deleted that the landing deletes
|
|
91
|
+
* orphans [rel] changed in the room but NOT landed: no signed receipt (old client, provenance off)
|
|
92
|
+
* holders [{ file, by, intent, leftSec }] set when refused for claims (Q575)
|
|
93
|
+
* landed number commits made by this run (0 when nothing new)
|
|
94
|
+
* tip string the branch tip after this run
|
|
95
|
+
* tests { ran: false } | { ran: true, command, ok, code, sha, tail, seconds }
|
|
96
|
+
* pushed boolean
|
|
97
|
+
* pr null | { number, url, created, updated, newCommits }
|
|
98
|
+
* notes [string] things the caller should pass on (a lock taken over, credentials removed ...)
|
|
99
|
+
*/
|
|
100
|
+
export async function landAndOpen(opts = {}) {
|
|
101
|
+
const o = opts || {}
|
|
102
|
+
const log = o.log || (() => {}), logErr = o.logErr || (() => {})
|
|
103
|
+
const dry = o.dry !== false
|
|
104
|
+
const wantPr = !!o.pr
|
|
105
|
+
const push = !!(o.push || o.pr)
|
|
106
|
+
const by = String(o.by || os.userInfo().username || 'someone').slice(0, 80)
|
|
107
|
+
const res = { ok: false, code: '', why: '', dry, plan: [], hashes: {}, deletions: [], orphans: [], holders: [], landed: 0, tip: '', tests: { ran: false }, pushed: false, pr: null, notes: [] }
|
|
108
|
+
const refuse = (code, why, extra = {}) => {
|
|
109
|
+
const text = String(why || '').trim()
|
|
110
|
+
return Object.assign(res, extra, { ok: false, code, why: text.startsWith(code) ? text : `${code} ${text}` })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let lock = null
|
|
114
|
+
let tmp = '', temp = '', root = '', junction = ''
|
|
115
|
+
const cleanup = () => {
|
|
116
|
+
if (!root || !tmp) return
|
|
117
|
+
// The junction to the developer's node_modules goes FIRST, by itself: removing the worktree
|
|
118
|
+
// with it in place could walk through it into the developer's own dependencies.
|
|
119
|
+
if (junction) { try { fs.unlinkSync(junction) } catch { try { fs.rmdirSync(junction) } catch { } } }
|
|
120
|
+
git(root, ['worktree', 'remove', '--force', tmp])
|
|
121
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }) } catch { }
|
|
122
|
+
git(root, ['worktree', 'prune'])
|
|
123
|
+
if (temp && HB.revParse(root, `refs/heads/${temp}`)) git(root, ['branch', '-D', temp])
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
root = HB.projectRoot(o.cwd || process.cwd())
|
|
128
|
+
const name = HB.validBranch(root, String(o.branch || ''))
|
|
129
|
+
const b = HB.readBinding(root, name)
|
|
130
|
+
if (!b) return refuse('Q562', `branch "${name}" has no Hivecode room here. Create one with: npx -y hivecode-mcp@latest branch ${name}`)
|
|
131
|
+
const tip = HB.revParse(root, `refs/heads/${name}`)
|
|
132
|
+
if (!tip) return refuse('Q563', `branch "${name}" does not exist in ${root}. Fetch it first: git fetch origin ${name}:${name}`)
|
|
133
|
+
res.tip = tip
|
|
134
|
+
|
|
135
|
+
// ── 1. ONE LANDING PER BRANCH ────────────────────────────────────────────────────────────
|
|
136
|
+
if (!dry) {
|
|
137
|
+
lock = takeLock(root, name, by)
|
|
138
|
+
if (!lock.ok) return refuse('Q579', lock.why)
|
|
139
|
+
if (lock.note) { res.notes.push(lock.note); log(lock.note) }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── 2. THE ROOM AS IT IS: its files and its live claims, from one passive look ─────────────
|
|
143
|
+
const snap = await roomSnapshot(b.link)
|
|
144
|
+
if (!snap) return refuse('Q565', `could not reach the relay behind this room, so nothing about it can be checked. Nothing was changed.`)
|
|
145
|
+
if (o.claims !== false && snap.holders.length) {
|
|
146
|
+
return refuse('Q575', `not landed: ${describeHolders(snap.holders)}. A claim means someone is still editing; landing now would put half a change on ${name}. When they release, run this again. Nothing was changed.`, { holders: snap.holders })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── 3. THE REMOTE: refused BEFORE anything moves, never force-pushed ──────────────────────
|
|
150
|
+
let github = null, into = ''
|
|
151
|
+
if (push && !dry) {
|
|
152
|
+
if (git(root, ['remote', 'get-url', 'origin']).code !== 0) return refuse('Q578', `there is no remote named origin in ${root}, so there is nothing to push to. Land without --push/--pr, or add the remote. Nothing was changed.`)
|
|
153
|
+
const f = gitNet(root, ['fetch', '--quiet', 'origin', `+refs/heads/${name}:refs/remotes/origin/${name}`])
|
|
154
|
+
const absent = f.code !== 0 && /couldn't find remote ref|could not find remote ref|no such ref/i.test(f.err)
|
|
155
|
+
if (f.code !== 0 && !absent) return refuse('Q578', `could not reach origin to check ${name} before landing: ${oneLine(f.err)}${loginHint(f.err)} Nothing was changed.`)
|
|
156
|
+
const remote = absent ? '' : HB.revParse(root, `refs/remotes/origin/${name}`)
|
|
157
|
+
if (remote && git(root, ['merge-base', '--is-ancestor', remote, tip]).code !== 0) {
|
|
158
|
+
const n = git(root, ['rev-list', '--count', `${tip}..${remote}`]).out
|
|
159
|
+
const last = git(root, ['log', '-1', '--format=%h %an: %s', remote]).out
|
|
160
|
+
return refuse('Q578', `origin has ${n} commit(s) on ${name} that this repo does not have (latest: ${last}). Bring them in first (git pull on ${name}), then land again. Nothing was changed; Hivecode never force-pushes.`)
|
|
161
|
+
}
|
|
162
|
+
if (wantPr) {
|
|
163
|
+
into = o.base || b.from
|
|
164
|
+
if (!into) return refuse('Q574', `there is no branch to open the PR into; pass --base <branch>. Nothing was changed.`)
|
|
165
|
+
try { github = o.github || await defaultGitHub(root) } catch (e) { return refuse('Q578', `${e.message} Nothing was changed.`) }
|
|
166
|
+
try { github.user() } catch (e) { return refuse('Q578', `GitHub login check failed: ${e.message} Nothing was changed.`) }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── 4. LAND IN A TEMPORARY WORKTREE (the developer's checkout is never switched) ──────────
|
|
171
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-land-'))
|
|
172
|
+
temp = `hivecode-land/${HB.safeSegment(name)}-${process.pid}-${Date.now()}`
|
|
173
|
+
const wt = git(root, HB.noHooks(['worktree', 'add', '--detach', tmp, tip]))
|
|
174
|
+
if (wt.code !== 0) return refuse('Q563', `could not make a temporary worktree: ${wt.err}`)
|
|
175
|
+
|
|
176
|
+
// The room's deletions, accounted for BEFORE anything is written. An empty room is not a room
|
|
177
|
+
// whose agents deleted everything: it is a room the relay lost.
|
|
178
|
+
const atTip = HB.treeFiles(root, tip)
|
|
179
|
+
const carries = HB.roomCarries(atTip)
|
|
180
|
+
const carried = atTip.filter(carries).filter((f) => !HB.isOwnFile(f.rel))
|
|
181
|
+
if (carried.length && snap.paths.size === 0) {
|
|
182
|
+
return refuse('Q577', `the room for ${name} holds no files, but the branch has ${carried.length} that a room carries. The relay most likely lost the room (a restart or a deploy wipes rooms on some hosts); landing now would delete every one of them. Nothing was changed.\nAgents that still have their folders bring the work back by joining again. If none does, refill the room from the branch:\n npx -y hivecode-mcp@latest branch ${name} --reseed`)
|
|
183
|
+
}
|
|
184
|
+
if (b.base && !HB.revParse(root, `${b.base}^{commit}`)) {
|
|
185
|
+
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.`)
|
|
186
|
+
}
|
|
187
|
+
if (!b.base) {
|
|
188
|
+
// A binding with no seed record (a `--join` made before 0.13.1, or a room seeded before it):
|
|
189
|
+
// deletions cannot be told from files added to the branch later, so none are landed -- and
|
|
190
|
+
// that is SAID, with the files, instead of the silent skip it used to be.
|
|
191
|
+
const absent = carried.filter((f) => !snap.paths.has(f.rel)).map((f) => f.rel)
|
|
192
|
+
if (absent.length) {
|
|
193
|
+
const note = `deletions NOT landed: this clone has no record of the commit the room was seeded from, so ${absent.length} file(s) on ${name} that are not in the room were left as they are (${absent.slice(0, 5).join(', ')}${absent.length > 5 ? ', ...' : ''}). Run \`npx -y hivecode-mcp@latest branch ${name} --join "<link>"\` again to record it.`
|
|
194
|
+
res.notes.push(note); log(note)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const deletions = roomDeletions({ root, room: parseLink(b.link).room, base: b.base, tip, paths: snap.paths, atTip, carries })
|
|
198
|
+
res.deletions = deletions
|
|
199
|
+
if (deletions.length > 3 && deletions.length * 2 > carried.length && !o.allowMassDelete) {
|
|
200
|
+
return refuse('Q577', `this landing would delete ${deletions.length} of the ${carried.length} files on ${name} (${deletions.slice(0, 5).join(', ')}${deletions.length > 5 ? ', ...' : ''}). That is what a room the relay partly lost looks like. If the agents really deleted them, run again with --allow-mass-delete. Nothing was changed.`)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const planFile = path.join(tmp, '..', `${path.basename(tmp)}-plan.json`)
|
|
204
|
+
const args = [HIVE_PR, b.link, 'hive-pr', '--repo', tmp, '--branch', temp, '--base', 'HEAD', '--plan-json', planFile]
|
|
205
|
+
if (dry) args.push('--dry-run')
|
|
206
|
+
if (o.force) args.push('--force', String(o.force))
|
|
207
|
+
if (o.only) args.push('--only', String(o.only))
|
|
208
|
+
// ASYNC, not spawnSync: inside the hive_pr tool this runs in the agent's MCP server, and a landing
|
|
209
|
+
// that blocked its event loop for minutes would freeze that agent's own room connection too.
|
|
210
|
+
const r = await runNode(args, { cwd: tmp, env: HB.noHooksEnv({ ...process.env, HIVE_PROVENANCE: 'on' }) })
|
|
211
|
+
readPlan(planFile, r.stdout, res)
|
|
212
|
+
try { fs.rmSync(planFile, { force: true }) } catch { }
|
|
213
|
+
if (r.stdout) log(r.stdout.replace(/\s+$/, ''))
|
|
214
|
+
// Q501 is hive-pr saying no file in the room carries an agent's receipt: nothing was changed or
|
|
215
|
+
// added. For a branch room that is an answer, not a failure -- and deletions may still land.
|
|
216
|
+
const nothingChanged = r.status !== 0 && /^Q501 /m.test(r.stderr || '')
|
|
217
|
+
if (r.status !== 0 && !nothingChanged) {
|
|
218
|
+
const msg = String(r.stderr || r.error || `hive-pr exited ${r.status}`).replace(/\s+$/, '')
|
|
219
|
+
const code = (msg.match(/\b(Q5\d\d)\b/) || [])[1] || 'Q563'
|
|
220
|
+
return refuse(code, msg)
|
|
221
|
+
}
|
|
222
|
+
if (res.orphans.length) {
|
|
223
|
+
const line = `NOT landed (no signed receipt): ${res.orphans.join(', ')} -- changed in the room by a client that signs nothing (an older Hivecode, or provenance off). Whoever changed it should save it again with Hivecode 0.13 or later.`
|
|
224
|
+
res.notes.push(line); log(line)
|
|
225
|
+
}
|
|
226
|
+
if (dry) {
|
|
227
|
+
if (deletions.length) log(`would delete ${deletions.length} file(s) the room deleted: ${deletions.join(', ')}`)
|
|
228
|
+
log(o.test ? `tests: would run \`${o.test}\` before anything moves` : 'tests: NOT RUN (no test command; pass --test "<command>")')
|
|
229
|
+
return Object.assign(res, { ok: true, why: dryWhy(name, res) })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (deletions.length) {
|
|
233
|
+
// hive-pr left the temporary worktree on its branch when it committed; when it had nothing to
|
|
234
|
+
// commit it never made one, so make it here, at the same tip.
|
|
235
|
+
if (!HB.revParse(root, `refs/heads/${temp}`)) git(tmp, HB.noHooks(['checkout', '-q', '-b', temp]))
|
|
236
|
+
const rm = git(tmp, ['rm', '-q', '--', ...deletions])
|
|
237
|
+
if (rm.code !== 0) return refuse('Q563', `could not stage the room's deletions: ${rm.err}`)
|
|
238
|
+
const msg = [`Room: delete ${deletions.length === 1 ? deletions[0] : `${deletions.length} files`}`, '', ...deletions.map((d) => ` - ${d}`), '', `Room: ${parseLink(b.link).room}`].join('\n')
|
|
239
|
+
const c = git(tmp, HB.noHooks(['commit', '-q', '-m', msg, '--author', 'hivecode-room <room@agents.hivecode.local>']))
|
|
240
|
+
if (c.code !== 0) return refuse('Q563', `could not commit the room's deletions: ${c.err || c.out}`)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// NOTHING NEW TO LAND IS NOT "DONE" WHEN A PUSH OR A PR WAS ASKED FOR (1 Nov 2026): push and
|
|
244
|
+
// open what is already there.
|
|
245
|
+
let nothingNew = false
|
|
246
|
+
if (nothingChanged && !deletions.length) { log(`nothing to land yet: no agent has changed a file in the room for ${name}.`); nothingNew = true }
|
|
247
|
+
const newTip = nothingNew ? tip : HB.revParse(root, `refs/heads/${temp}`)
|
|
248
|
+
if (!nothingNew && (!newTip || newTip === tip)) { log(`nothing to land: ${name} already has everything the room holds.`); nothingNew = true }
|
|
249
|
+
|
|
250
|
+
if (!nothingNew) {
|
|
251
|
+
// ── 5. CLAIMS AGAIN: a claim taken while this ran means a change was caught half-made ─────
|
|
252
|
+
if (o.claims !== false) {
|
|
253
|
+
const again = await roomSnapshot(b.link)
|
|
254
|
+
if (again && again.holders.length) return refuse('Q575', `not landed: ${describeHolders(again.holders)} -- started while this landing ran, so a change may have been caught half-made. Run this again when they release. Nothing was changed.`, { holders: again.holders })
|
|
255
|
+
}
|
|
256
|
+
const changed = git(root, ['diff', '--name-only', '-z', tip, newTip]).out.split('\0').filter(Boolean)
|
|
257
|
+
// ── 6. WHAT MAY NOT LEAVE THIS MACHINE ─────────────────────────────────────────────────────
|
|
258
|
+
if (o.checks !== false) {
|
|
259
|
+
const workflows = o.allowWorkflowChanges ? [] : changed.filter((p) => /^\.github\/workflows\//i.test(p))
|
|
260
|
+
if (workflows.length) return refuse('Q578', `not landed: the change touches ${workflows.join(', ')}. A workflow runs with the repository's secrets, so it lands only when asked for explicitly: run again with --allow-workflow-changes. Nothing was changed.`)
|
|
261
|
+
const leaked = secretsIn(root, newTip, changed)
|
|
262
|
+
if (leaked.length) return refuse('Q578', `not landed: the change has ${leaked.join(', ')}. Remove it from the room's copy, then land again. Nothing was changed.`)
|
|
263
|
+
}
|
|
264
|
+
// ── 7. THE TESTS, on the exact commit that would move ──────────────────────────────────────
|
|
265
|
+
if (o.test) {
|
|
266
|
+
const at = git(tmp, ['rev-parse', 'HEAD']).out
|
|
267
|
+
let cwdTests = tmp
|
|
268
|
+
// A CAN'T-HAPPEN INVARIANT, kept on purpose: hive-pr commits in this worktree, so its HEAD is
|
|
269
|
+
// the new tip. No test reaches this line (antigravity's mutant 3, 5 Nov 2026); if it ever
|
|
270
|
+
// fired, a pass would be a pass on a different commit than the one that moves.
|
|
271
|
+
if (at !== newTip) {
|
|
272
|
+
return refuse('Q576', `the test checkout is at ${short(at)}, not at the commit that would land (${short(newTip)}). Nothing was changed.`)
|
|
273
|
+
}
|
|
274
|
+
junction = linkDependencies(root, cwdTests, newTip, res)
|
|
275
|
+
const env = scrubbedEnv(process.env, res)
|
|
276
|
+
const minutes = Number(o.testMinutes) > 0 ? Number(o.testMinutes) : 20
|
|
277
|
+
// TESTS THAT NEED WHAT GIT DOES NOT HAVE (debate #3: all three agents ranked it a stranger's
|
|
278
|
+
// top-2 wall). Files the developer NAMED are copied from their checkout into the clean one;
|
|
279
|
+
// a setup command runs first. Both come from the caller or from what the developer saved for
|
|
280
|
+
// this branch -- never guessed, and never a whole .env by default.
|
|
281
|
+
const saved = testConfigFor(root, name)
|
|
282
|
+
const copyList = (Array.isArray(o.testCopy) ? o.testCopy : saved.copy).filter(Boolean)
|
|
283
|
+
const setup = typeof o.testSetup === 'string' ? o.testSetup : saved.setup
|
|
284
|
+
for (const rel of copyList) {
|
|
285
|
+
const src = path.resolve(root, rel), dest = path.resolve(cwdTests, rel)
|
|
286
|
+
const inside = (base, p) => p === base || p.startsWith(base + path.sep)
|
|
287
|
+
// `C:foo` is not absolute to Node on Windows and resolves INSIDE the repo lexically; a
|
|
288
|
+
// .git path would write into git's own data (Antigravity's review).
|
|
289
|
+
if (path.isAbsolute(rel) || /^[A-Za-z]:/.test(rel) || /(^|[\\/])\.git([\\/]|$)/i.test(rel) || !inside(path.resolve(root), src) || !inside(path.resolve(cwdTests), dest)) return refuse('Q576', `--test-copy names ${rel}, which is outside the repository. Only files inside ${root} can be copied for the tests. Nothing was changed.`)
|
|
290
|
+
if (!fs.existsSync(src) || !fs.lstatSync(src).isFile()) return refuse('Q576', `--test-copy names ${rel}, but there is no such regular file in ${root}${fs.existsSync(src) && fs.lstatSync(src).isSymbolicLink() ? ' (it is a link, and a link can point anywhere on this machine)' : ''}. Nothing was changed.`)
|
|
291
|
+
// A TRACKED FILE IS THE ROOM'S: copying the developer's copy over it would test old code
|
|
292
|
+
// and land the room's untested edit -- green, and wrong (Antigravity's review).
|
|
293
|
+
if (git(root, ['cat-file', '-e', `${newTip}:${rel.replace(/\\/g, '/')}`]).code === 0) return refuse('Q576', `--test-copy names ${rel}, which is part of the commit being tested. --test-copy is only for files git does not have (an .env.test, a local config); copying your copy over it would test something other than what lands. Nothing was changed.`)
|
|
294
|
+
if (!inside(fs.realpathSync(root), fs.realpathSync(src))) return refuse('Q576', `--test-copy names ${rel}, which resolves outside the repository. Nothing was changed.`)
|
|
295
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
|
296
|
+
if (!inside(fs.realpathSync(cwdTests), fs.realpathSync(path.dirname(dest))) || (fs.existsSync(dest) && fs.lstatSync(dest).isSymbolicLink())) return refuse('Q576', `--test-copy cannot write ${rel}: in the commit being tested that path is (or is under) a link that points outside the checkout. Nothing was changed.`)
|
|
297
|
+
fs.copyFileSync(src, dest)
|
|
298
|
+
}
|
|
299
|
+
if (copyList.length) { const note = `the tests had ${copyList.length} uncommitted file(s) copied in from your checkout: ${copyList.join(', ')}.`; res.notes.push(note); log(note) }
|
|
300
|
+
if (setup) {
|
|
301
|
+
log(`tests: setup \`${setup}\` ...`)
|
|
302
|
+
const s = await runTests({ cwd: cwdTests, command: setup, minutes, env })
|
|
303
|
+
if (!s.ok) return refuse('Q576', `not landed: the test setup \`${setup}\` ${!s.started ? 'could not start' : s.timedOut ? 'ran past the time limit' : `failed (exit ${s.code})`}.\n${s.tail}\nNothing was changed.`)
|
|
304
|
+
}
|
|
305
|
+
log(`tests: running \`${o.test}\` on ${short(newTip)} ...`)
|
|
306
|
+
const t = await runTests({ cwd: cwdTests, command: o.test, minutes, env })
|
|
307
|
+
res.tests = { ran: true, command: o.test, ok: t.ok, code: t.code, sha: newTip, tail: t.tail, seconds: t.seconds }
|
|
308
|
+
if (!t.ok) {
|
|
309
|
+
const why = !t.started ? `the test command could not start (\`${o.test}\`); check that it is installed on this machine`
|
|
310
|
+
: t.timedOut ? `the tests ran past the ${minutes}-minute time limit and were stopped`
|
|
311
|
+
: `the tests failed (exit ${t.code}) on ${short(newTip)}`
|
|
312
|
+
const deps = /Cannot find (module|package)|ERR_MODULE_NOT_FOUND|MODULE_NOT_FOUND/.test(t.tail) && !junction
|
|
313
|
+
? '\nThe tests ran in a clean checkout without installed dependencies; add the install step: --test-setup "npm ci" --save.' : ''
|
|
314
|
+
const files = /ENOENT|no such file|not found|cannot open/i.test(t.tail) && !copyList.length
|
|
315
|
+
? `\nThe tests ran in a clean checkout, which has only what git has. If they need a file you keep uncommitted (an .env.test, a local config), name it: --test-copy <file>[,<file>] --save.` : ''
|
|
316
|
+
return refuse('Q576', `not landed: ${why}.\n${t.tail}${deps}${files}\nNothing was changed.`)
|
|
317
|
+
}
|
|
318
|
+
log(`tests passed on ${short(newTip)} (\`${o.test}\`, ${t.seconds}s)`)
|
|
319
|
+
} else {
|
|
320
|
+
log('tests: NOT RUN (no test command; pass --test "<command>")')
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── 8. MOVE THE BRANCH ─────────────────────────────────────────────────────────────────────
|
|
324
|
+
// Checked out somewhere: only a clean fast-forward of that checkout, which is what `git pull`
|
|
325
|
+
// would do; with local changes it is refused, because moving the branch under a dirty checkout
|
|
326
|
+
// makes its edits look like reversals of the landed work.
|
|
327
|
+
const on = worktreesOn(root, name).filter((p) => p !== path.resolve(tmp))
|
|
328
|
+
if (on.length) {
|
|
329
|
+
const where = on[0]
|
|
330
|
+
const dirty = git(where, ['status', '--porcelain', '--untracked-files=no'])
|
|
331
|
+
if (dirty.out) return refuse('Q571', `${name} is checked out in ${where} with uncommitted changes. Commit or stash them there (or switch that checkout to another branch), then run pr again. Nothing was changed.`)
|
|
332
|
+
const ff = git(where, ['merge', '--ff-only', '--quiet', newTip])
|
|
333
|
+
if (ff.code !== 0) return refuse('Q570', `could not fast-forward ${name} in ${where}: ${ff.err || 'git refused'}. Nothing was changed; if the branch moved there, run pr again.`)
|
|
334
|
+
} else {
|
|
335
|
+
const cas = git(root, ['update-ref', '-m', 'hivecode: land room work', `refs/heads/${name}`, newTip, tip])
|
|
336
|
+
if (cas.code !== 0) return refuse('Q570', `${name} moved while this was landing (it is no longer ${short(tip)}). Nothing was changed; run pr again so the new commits are accounted for.`)
|
|
337
|
+
}
|
|
338
|
+
const made = git(root, ['log', '--format= %h %an %s', `${tip}..${newTip}`]).out
|
|
339
|
+
res.landed = made.split('\n').filter(Boolean).length
|
|
340
|
+
res.tip = newTip
|
|
341
|
+
log(`\nlanded on ${name}:\n${made}`)
|
|
342
|
+
} else if (!o.test) {
|
|
343
|
+
log('tests: NOT RUN (nothing new landed)')
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── 9. PUSH THE EXACT SHA, never forced ──────────────────────────────────────────────────────
|
|
347
|
+
if (push) {
|
|
348
|
+
const sha = res.tip
|
|
349
|
+
const p = gitNet(root, ['push', '--quiet', 'origin', `${sha}:refs/heads/${name}`], Number(process.env.HIVE_GIT_NET_TIMEOUT_MS) || 300000)
|
|
350
|
+
if (p.code !== 0) return refuse('Q573', `the work ${res.landed ? 'landed' : 'is'} on ${name} locally (${short(sha)}), but the push failed: ${oneLine(p.err)}${loginHint(p.err)}`)
|
|
351
|
+
git(root, ['branch', '--quiet', `--set-upstream-to=origin/${name}`, name])
|
|
352
|
+
res.pushed = true
|
|
353
|
+
log(`pushed ${name} (${short(sha)})`)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── 10. THE PR: update the open one, never a second ─────────────────────────────────────────
|
|
357
|
+
if (wantPr) {
|
|
358
|
+
const prs = (github.pullsFor(name) || []).filter((p) => !p.head || !p.head.ref || p.head.ref === name)
|
|
359
|
+
const open = prs.find((p) => p.state === 'open')
|
|
360
|
+
if (open) {
|
|
361
|
+
res.pr = { number: open.number, url: open.html_url || '', created: false, updated: true, newCommits: res.landed }
|
|
362
|
+
log(`Updated PR #${open.number} (${res.landed} new commit(s)): ${open.html_url || ''}`)
|
|
363
|
+
} else {
|
|
364
|
+
const intoRef = HB.revParse(root, `refs/remotes/origin/${into}`) ? `origin/${into}` : into
|
|
365
|
+
const ahead = Number(git(root, ['rev-list', '--count', `${intoRef}..${res.tip}`]).out) || 0
|
|
366
|
+
if (!ahead) {
|
|
367
|
+
const note = `no PR opened: ${name} has no commits beyond ${into}.`
|
|
368
|
+
res.notes.push(note); log(note)
|
|
369
|
+
} else {
|
|
370
|
+
const body = prBody({ name, into: intoRef, root, summary: o.summary, by, res, force: o.force })
|
|
371
|
+
let pr = null
|
|
372
|
+
try { pr = github.createPull({ title: o.title || `Hivecode: ${name}`, head: name, base: into, body }) } catch (e) {
|
|
373
|
+
const again = e && e.status === 422 ? (github.pullsFor(name) || []).find((p) => p.state === 'open') : null
|
|
374
|
+
if (!again) return refuse('Q574', `the work is pushed to ${name}, but opening the PR failed: ${e.message}`)
|
|
375
|
+
pr = again
|
|
376
|
+
}
|
|
377
|
+
const closed = prs.filter((p) => p.state !== 'open').map((p) => `#${p.number}`)
|
|
378
|
+
if (closed.length) { const note = `the earlier PR for ${name} (${closed.join(', ')}) was closed or merged; this is a new one.`; res.notes.push(note); log(note) }
|
|
379
|
+
res.pr = { number: pr.number, url: pr.html_url || '', created: true, updated: false, newCommits: res.landed }
|
|
380
|
+
log(`Opened PR #${pr.number}: ${pr.html_url || ''}`)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
res.ok = true
|
|
386
|
+
res.why = doneWhy(name, res)
|
|
387
|
+
return res
|
|
388
|
+
} catch (e) {
|
|
389
|
+
if (e && typeof e.code === 'string' && /^Q\d{3}$/.test(e.code)) return refuse(e.code, e.message)
|
|
390
|
+
throw e
|
|
391
|
+
} finally {
|
|
392
|
+
cleanup()
|
|
393
|
+
if (lock && lock.ok) lock.release()
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* THE TEST COMMAND FOR A BRANCH, from configuration and never from whoever is asking: what the
|
|
399
|
+
* developer saved (`pr <branch> --test "<cmd>" --save`), else the project's own `npm test` when
|
|
400
|
+
* package.json at the branch tip has a real test script (not npm's "no test specified"), else ''.
|
|
401
|
+
* A tool argument would let any caller choose a shell command for Hivecode to run on this machine.
|
|
402
|
+
*/
|
|
403
|
+
export function testCommandFor(cwd, branch) {
|
|
404
|
+
let root
|
|
405
|
+
try { root = HB.projectRoot(cwd) } catch { return { command: '', from: '' } }
|
|
406
|
+
const saved = git(root, ['config', '--local', '--get', `hivecode.${branch}.test`])
|
|
407
|
+
if (saved.code === 0 && saved.out) return { command: saved.out, from: 'saved' }
|
|
408
|
+
const pj = git(root, ['cat-file', 'blob', `refs/heads/${branch}:package.json`])
|
|
409
|
+
try {
|
|
410
|
+
const t = JSON.parse(pj.out || '{}').scripts?.test
|
|
411
|
+
if (typeof t === 'string' && t.trim() && !/no test specified/i.test(t)) return { command: 'npm test', from: 'package.json' }
|
|
412
|
+
} catch { /* not JSON: no project test script */ }
|
|
413
|
+
return { command: '', from: '' }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function saveTestCommand(cwd, branch, command, { copy = null, setup = null } = {}) {
|
|
417
|
+
const root = HB.projectRoot(cwd)
|
|
418
|
+
const set = (k, v) => {
|
|
419
|
+
const r = git(root, ['config', '--local', `hivecode.${branch}.${k}`, String(v)])
|
|
420
|
+
if (r.code !== 0) throw HB.coded('Q576', `could not save ${k} in git config: ${r.err}`)
|
|
421
|
+
}
|
|
422
|
+
set('test', command)
|
|
423
|
+
if (Array.isArray(copy)) { if (copy.length) set('testCopy', copy.join(',')); else git(root, ['config', '--local', '--unset', `hivecode.${branch}.testCopy`]) }
|
|
424
|
+
if (typeof setup === 'string') { if (setup) set('testSetup', setup); else git(root, ['config', '--local', '--unset', `hivecode.${branch}.testSetup`]) }
|
|
425
|
+
return root
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** What the developer saved for the tests besides the command: files to copy in, a setup step. */
|
|
429
|
+
export function testConfigFor(cwdOrRoot, branch) {
|
|
430
|
+
let root
|
|
431
|
+
try { root = HB.projectRoot(cwdOrRoot) } catch { return { copy: [], setup: '' } }
|
|
432
|
+
const get = (k) => { const r = git(root, ['config', '--local', '--get', `hivecode.${branch}.${k}`]); return r.code === 0 ? r.out : '' }
|
|
433
|
+
return { copy: get('testCopy').split(',').map((s) => s.trim()).filter(Boolean), setup: get('testSetup') }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── the pieces ─────────────────────────────────────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
const oneLine = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, 400)
|
|
439
|
+
function loginHint(err) {
|
|
440
|
+
return /auth|denied|permission|403|401|credential|could not read Username/i.test(String(err || ''))
|
|
441
|
+
? ' The login on this machine was refused: run gh auth login (or fix your git credentials), then try again.' : ''
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function describeHolders(holders) {
|
|
445
|
+
return holders.map((h) => `${h.by} holds ${h.file} (${h.intent || 'edit'}; ${h.leftSec}s left on the claim)`).join('; ')
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** The room's file list and live claims, from a passive look that syncs no files. null: unreachable. */
|
|
449
|
+
async function roomSnapshot(link) {
|
|
450
|
+
const { relay, room } = parseLink(link)
|
|
451
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hive-land-peek-'))
|
|
452
|
+
const peek = startSync({ relay, room, dir, name: 'hive-land-peek', kind: 'ai', passive: true, syncFiles: false, log: () => {} })
|
|
453
|
+
try {
|
|
454
|
+
let synced = false
|
|
455
|
+
peek.provider.on('sync', (s) => { if (s) synced = true })
|
|
456
|
+
if (!(await HB.waitFor(() => synced, 15000))) return null
|
|
457
|
+
await sleep(300)
|
|
458
|
+
const now = Date.now()
|
|
459
|
+
const holders = makeCoordinator(peek.doc.getMap('claims'), 'hive-land-peek').board()
|
|
460
|
+
.filter((c) => c && c.by)
|
|
461
|
+
.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)) }))
|
|
462
|
+
return { paths: new Set(peek.doc.getMap('manifest').keys()), holders }
|
|
463
|
+
} finally {
|
|
464
|
+
try { peek.stop() } catch { }
|
|
465
|
+
try { fs.rmSync(dir, { recursive: true, force: true }) } catch { }
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* THE ROOM'S DELETIONS, which hive-pr does not land: it copies the files a room holds, so a file an
|
|
471
|
+
* agent deleted would simply stay on the branch. A room bound to a branch starts as the whole branch,
|
|
472
|
+
* so "on the branch but no longer in the room" is a deletion -- but only when the room provably held
|
|
473
|
+
* THAT version of the file:
|
|
474
|
+
* - last changed on the branch by a landing from this room (its commit says "Room: <id>") -> delete
|
|
475
|
+
* - unchanged since the seed commit -> delete
|
|
476
|
+
* - in the seed, but changed on the branch since by someone else -> REFUSE (Q572)
|
|
477
|
+
* - not in the seed and not landed from this room: added to the branch outside the room -> keep
|
|
478
|
+
*/
|
|
479
|
+
function roomDeletions({ root, room, base, tip, paths, atTip, carries }) {
|
|
480
|
+
if (!base) return []
|
|
481
|
+
const baseSha = new Map(HB.treeFiles(root, base).map((f) => [f.rel, f.sha]))
|
|
482
|
+
const gone = [], diverged = []
|
|
483
|
+
for (const f of atTip) {
|
|
484
|
+
if (!carries(f) || paths.has(f.rel) || HB.isOwnFile(f.rel)) continue
|
|
485
|
+
const last = git(root, ['log', '-1', '--format=%B', tip, '--', f.rel]).out
|
|
486
|
+
if (new RegExp(`^Room: ${reEsc(room)}$`, 'm').test(last)) gone.push(f.rel)
|
|
487
|
+
else if (baseSha.get(f.rel) === f.sha) gone.push(f.rel)
|
|
488
|
+
else if (baseSha.has(f.rel)) diverged.push(f.rel)
|
|
489
|
+
}
|
|
490
|
+
if (diverged.length) {
|
|
491
|
+
const who = (rel) => git(root, ['log', '-1', '--format=%h %an %ad %s', '--date=short', tip, '--', rel]).out
|
|
492
|
+
throw HB.coded('Q572', `the room deleted ${diverged.length} file(s) that were changed on the branch since it was seeded, by a change the room never saw:\n${diverged.map((r) => ` ${r} last changed by: ${who(r)}`).join('\n')}\nDeleting them would erase that change. Nothing was changed. Restore the file in the room (or delete it on the branch yourself), then run pr again.`)
|
|
493
|
+
}
|
|
494
|
+
return gone
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function worktreesOn(root, branch) {
|
|
498
|
+
const r = git(root, ['worktree', 'list', '--porcelain'])
|
|
499
|
+
const out = []
|
|
500
|
+
let cur = null
|
|
501
|
+
for (const line of r.out.split('\n')) {
|
|
502
|
+
if (line.startsWith('worktree ')) cur = line.slice(9)
|
|
503
|
+
else if (line === `branch refs/heads/${branch}` && cur) out.push(path.resolve(cur))
|
|
504
|
+
}
|
|
505
|
+
return out
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** hive-pr's plan (--plan-json), or what its stdout says when it is an older hive-pr without one. */
|
|
509
|
+
function readPlan(file, stdout, res) {
|
|
510
|
+
let plan = null
|
|
511
|
+
try { plan = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { /* an older hive-pr */ }
|
|
512
|
+
if (plan) {
|
|
513
|
+
// A group whose files are all already on the branch commits nothing: listing it would make a dry
|
|
514
|
+
// run promise "1 commit would land" when nothing would (measured: K4, 5 Nov 2026).
|
|
515
|
+
res.plan = (plan.groups || []).map((g) => ({ agent: g.name, files: g.files || [], intents: g.intents || [] })).filter((g) => g.files.length)
|
|
516
|
+
res.orphans = (plan.orphans || []).filter((rel) => !GENERATED.test(path.posix.basename(rel)) && !HB.isOwnFile(rel))
|
|
517
|
+
// The room's content hash per file, as the mirror read it: what a review must match, and what
|
|
518
|
+
// tells "the room changed since the last refused landing" apart from "same names, same failure".
|
|
519
|
+
if (plan.hashes && typeof plan.hashes === 'object') res.hashes = { ...plan.hashes }
|
|
520
|
+
return
|
|
521
|
+
}
|
|
522
|
+
const m = String(stdout || '').match(/\(\d+ file\(s\) with no verified receipt -- not committed: ([^)]*)\)/)
|
|
523
|
+
if (m) res.orphans = m[1].split(', ').map((s) => s.trim()).filter((rel) => rel && !GENERATED.test(path.posix.basename(rel)) && !HB.isOwnFile(rel))
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Keys in the files a change touched, at `tip`. Rooms never carry a file with a key in it, so this
|
|
527
|
+
* should never fire; it is the check on what leaves this machine, not on what the room did. */
|
|
528
|
+
function secretsIn(root, tip, changed) {
|
|
529
|
+
const found = []
|
|
530
|
+
for (const rel of changed) {
|
|
531
|
+
const size = Number(git(root, ['cat-file', '-s', `${tip}:${rel}`]).out || 0)
|
|
532
|
+
if (!size || size > 1_000_000) continue
|
|
533
|
+
const r = spawnSync('git', ['cat-file', 'blob', `${tip}:${rel}`], { cwd: root, encoding: 'utf8', windowsHide: true, maxBuffer: 8 * 1024 * 1024 })
|
|
534
|
+
const text = r.stdout || ''
|
|
535
|
+
if (text.includes('\0')) continue
|
|
536
|
+
for (const f of findSecrets(text)) found.push(`${f.kind} in ${rel} (line ${f.line})`)
|
|
537
|
+
}
|
|
538
|
+
return found
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ── one landing per branch ──────────────────────────────────────────────────────────────────────
|
|
542
|
+
|
|
543
|
+
function takeLock(root, branch, by) {
|
|
544
|
+
const common = path.resolve(root, git(root, ['rev-parse', '--git-common-dir']).out || '.git')
|
|
545
|
+
const dir = path.join(common, 'hivecode')
|
|
546
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
547
|
+
const file = path.join(dir, `land-${HB.safeSegment(branch)}.lock`)
|
|
548
|
+
const me = { pid: process.pid, host: os.hostname(), by, at: Date.now() }
|
|
549
|
+
let note = ''
|
|
550
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
551
|
+
try {
|
|
552
|
+
fs.writeFileSync(file, JSON.stringify(me), { flag: 'wx' })
|
|
553
|
+
return {
|
|
554
|
+
ok: true, note,
|
|
555
|
+
release: () => { try { const cur = JSON.parse(fs.readFileSync(file, 'utf8')); if (cur.pid === me.pid && cur.at === me.at) fs.unlinkSync(file) } catch { } },
|
|
556
|
+
}
|
|
557
|
+
} catch (e) {
|
|
558
|
+
if (e.code !== 'EEXIST') return { ok: false, why: `could not take the landing lock (${file}): ${e.message}` }
|
|
559
|
+
let rec = null
|
|
560
|
+
try { rec = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { /* half-written: treat as stale below */ }
|
|
561
|
+
const when = rec && rec.at ? new Date(rec.at).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : 'an unknown time'
|
|
562
|
+
const dead = !rec || (rec.host === os.hostname() && !alive(Number(rec.pid))) || Date.now() - (Number(rec.at) || 0) > 6 * 3600000
|
|
563
|
+
if (!dead) return { ok: false, why: `a landing of ${branch} is already running, started by ${rec.by || 'someone'} at ${when} (pid ${rec.pid} on ${rec.host}). Wait for it to finish, then run this again. Nothing was changed.` }
|
|
564
|
+
try { fs.unlinkSync(file) } catch { }
|
|
565
|
+
note = `took over the landing lock of ${branch} left by ${(rec && rec.by) || 'an unknown landing'} (pid ${rec && rec.pid}, ${when}), which is no longer running.`
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return { ok: false, why: `could not take the landing lock of ${branch}; another landing keeps taking it. Nothing was changed.` }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ── the tests ───────────────────────────────────────────────────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
// THE TESTS RUN THE AGENTS' CODE WITH THIS MACHINE'S ENVIRONMENT. Credentials are removed first: a
|
|
574
|
+
// test (or a prompt-injected agent's test) must not be able to read the GitHub token that pushes.
|
|
575
|
+
const CREDENTIAL = /(TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|APIKEY|PRIVATE_KEY|CREDENTIAL)|^AWS_|^GH_|^GITHUB_|^HIVE_LICENSE$/i
|
|
576
|
+
function scrubbedEnv(env, res) {
|
|
577
|
+
const out = {}
|
|
578
|
+
let removed = 0
|
|
579
|
+
for (const [k, v] of Object.entries(env)) { if (CREDENTIAL.test(k)) removed++; else out[k] = v }
|
|
580
|
+
if (removed) res.notes.push(`the tests ran with ${removed} credential variable(s) removed from their environment.`)
|
|
581
|
+
return out
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* A clean checkout has no node_modules, so a project's `npm test` fails there with "Cannot find
|
|
586
|
+
* module" -- on real repositories the likeliest failure of all. When the lockfiles at the landing
|
|
587
|
+
* commit are exactly the ones this checkout installed from, its node_modules is linked in (a
|
|
588
|
+
* junction on Windows); otherwise the command runs as given.
|
|
589
|
+
*/
|
|
590
|
+
function linkDependencies(root, dir, sha, res) {
|
|
591
|
+
const nm = path.join(root, 'node_modules')
|
|
592
|
+
if (!fs.existsSync(nm) || fs.existsSync(path.join(dir, 'node_modules'))) return ''
|
|
593
|
+
const LOCKS = ['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb']
|
|
594
|
+
let any = false
|
|
595
|
+
for (const f of LOCKS) {
|
|
596
|
+
let mine = null
|
|
597
|
+
try { mine = fs.readFileSync(path.join(root, f)) } catch { }
|
|
598
|
+
const at = spawnSync('git', ['cat-file', 'blob', `${sha}:${f}`], { cwd: root, windowsHide: true, maxBuffer: 64 * 1024 * 1024 })
|
|
599
|
+
const theirs = at.status === 0 ? at.stdout : null
|
|
600
|
+
if ((mine === null) !== (theirs === null)) return ''
|
|
601
|
+
if (mine !== null && !mine.equals(theirs)) return ''
|
|
602
|
+
if (mine !== null) any = true
|
|
603
|
+
}
|
|
604
|
+
if (!any) return ''
|
|
605
|
+
const link = path.join(dir, 'node_modules')
|
|
606
|
+
try { fs.symlinkSync(nm, link, 'junction') } catch { return '' }
|
|
607
|
+
res.notes.push('the tests used this checkout\'s node_modules (the lockfile at the landing commit is identical).')
|
|
608
|
+
return link
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function runNode(args, { cwd, env }) {
|
|
612
|
+
return new Promise((resolve) => {
|
|
613
|
+
let stdout = '', stderr = ''
|
|
614
|
+
let child
|
|
615
|
+
try { child = spawn(process.execPath, args, { cwd, env, windowsHide: true }) } catch (e) { return resolve({ status: 1, stdout: '', stderr: '', error: e }) }
|
|
616
|
+
child.stdout.on('data', (d) => { stdout += d })
|
|
617
|
+
child.stderr.on('data', (d) => { stderr += d })
|
|
618
|
+
child.on('error', (e) => { stderr += `\n${e.message}` })
|
|
619
|
+
child.on('close', (code) => resolve({ status: code === null ? 1 : code, stdout, stderr }))
|
|
620
|
+
})
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function killTree(pid) {
|
|
624
|
+
if (!pid) return
|
|
625
|
+
if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true })
|
|
626
|
+
else { try { process.kill(-pid, 'SIGKILL') } catch { try { process.kill(pid, 'SIGKILL') } catch { } } }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function runTests({ cwd, command, minutes, env }) {
|
|
630
|
+
return new Promise((resolve) => {
|
|
631
|
+
const t0 = Date.now()
|
|
632
|
+
let out = '', timedOut = false, child
|
|
633
|
+
try {
|
|
634
|
+
child = spawn(command, { cwd, env, shell: true, windowsHide: true, detached: process.platform !== 'win32' })
|
|
635
|
+
} catch (e) {
|
|
636
|
+
return resolve({ started: false, ok: false, code: -1, timedOut: false, tail: String(e.message), seconds: 0 })
|
|
637
|
+
}
|
|
638
|
+
const add = (d) => { out += d; if (out.length > 400000) out = out.slice(-200000) }
|
|
639
|
+
child.stdout.on('data', add)
|
|
640
|
+
child.stderr.on('data', add)
|
|
641
|
+
const timer = setTimeout(() => { timedOut = true; killTree(child.pid) }, Math.max(1000, minutes * 60000))
|
|
642
|
+
child.on('error', (e) => add(`\n${e.message}`))
|
|
643
|
+
child.on('close', (code) => {
|
|
644
|
+
clearTimeout(timer)
|
|
645
|
+
const notFound = !timedOut && (code === 127 || code === 9009 || /is not recognized as an internal or external command|command not found|: not found\b/i.test(out))
|
|
646
|
+
resolve({ started: !notFound, ok: !timedOut && code === 0, code, timedOut, tail: tailLines(out, 30), seconds: Math.round((Date.now() - t0) / 1000) })
|
|
647
|
+
})
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ── GitHub ──────────────────────────────────────────────────────────────────────────────────────
|
|
652
|
+
|
|
653
|
+
async function defaultGitHub(root) {
|
|
654
|
+
const GH = await import('./hive-github.js')
|
|
655
|
+
const url = git(root, ['remote', 'get-url', 'origin']).out
|
|
656
|
+
const r = GH.parseRepo(url)
|
|
657
|
+
if (!r) throw HB.coded('Q578', `origin (${url}) is not a GitHub repository, so there is no pull request to open. Push without --pr.`)
|
|
658
|
+
return GH.makeGitHub({ call: GH.ghCall(), owner: r.owner, repo: r.repo })
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function prBody({ name, into, root, summary, by, res, force }) {
|
|
662
|
+
const commits = git(root, ['log', '--format= %h %an %s', `${into}..${name}`]).out || git(root, ['log', '-20', '--format= %h %an %s', name]).out
|
|
663
|
+
const tests = res.tests && res.tests.ran
|
|
664
|
+
? `Tests: \`${res.tests.command}\` passed on ${short(res.tests.sha)}, the exact commit pushed.`
|
|
665
|
+
: 'Tests: **NOT RUN** -- no test command was given for this landing.'
|
|
666
|
+
return [
|
|
667
|
+
`Opened by Hivecode from the room bound to \`${name}\`${by ? `, landed by ${by}` : ''}.`,
|
|
668
|
+
summary ? `\n${String(summary).slice(0, 4000)}` : '',
|
|
669
|
+
'',
|
|
670
|
+
tests,
|
|
671
|
+
res.orphans.length ? `\n> **Not in this PR:** ${res.orphans.join(', ')} changed in the room without a signed receipt.` : '',
|
|
672
|
+
res.deletions.length ? `\nDeleted by the room: ${res.deletions.join(', ')}` : '',
|
|
673
|
+
force ? `\n> **Forced past a staleness refusal:** ${String(force).slice(0, 500)}` : '',
|
|
674
|
+
'',
|
|
675
|
+
'Each commit carries its agent as the git author and the signed author id as a `Hivecode-Author` trailer.',
|
|
676
|
+
'', '```', commits, '```',
|
|
677
|
+
].filter((l) => l !== null).join('\n')
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function dryWhy(name, res) {
|
|
681
|
+
const n = res.plan.length
|
|
682
|
+
return `dry run: ${n ? `${n} commit(s) would land on ${name} (${res.plan.map((g) => `${g.agent}: ${g.files.length} file(s)`).join(', ')})` : `nothing new would land on ${name}`}${res.deletions.length ? `, deleting ${res.deletions.length} file(s)` : ''}${res.orphans.length ? `; NOT landing ${res.orphans.length} unsigned file(s): ${res.orphans.join(', ')}` : ''}. Nothing was changed.`
|
|
683
|
+
}
|
|
684
|
+
function doneWhy(name, res) {
|
|
685
|
+
const parts = [res.landed ? `landed ${res.landed} commit(s) on ${name} (${short(res.tip)})` : `nothing new to land on ${name}`]
|
|
686
|
+
parts.push(res.tests.ran ? `tests passed on ${short(res.tests.sha)}` : 'tests: NOT RUN')
|
|
687
|
+
if (res.pushed) parts.push('pushed')
|
|
688
|
+
if (res.pr) parts.push(res.pr.updated ? `updated PR #${res.pr.number} ${res.pr.url}` : `opened PR #${res.pr.number} ${res.pr.url}`)
|
|
689
|
+
if (res.orphans.length) parts.push(`NOT landed (no signed receipt): ${res.orphans.join(', ')}`)
|
|
690
|
+
return parts.join('; ')
|
|
691
|
+
}
|