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-notify.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// hive-notify -- tell a person, on Slack or Discord, when an agent in a room is waiting on them.
|
|
2
|
+
//
|
|
3
|
+
// npx -y hivecode-mcp@latest notify "<link>" --webhook <url> --for <name>[,<name>] [--every <minutes>] [--title <text>]
|
|
4
|
+
//
|
|
5
|
+
// WHY. On 10 Oct 2026 a two-agent demo stalled for an hour: one agent waited for its person's OK,
|
|
6
|
+
// the other waited for it, and the person was not looking at the chat. The room knew. Nobody was
|
|
7
|
+
// told. An owner who leaves agents working has to hear about the two things only they can unblock:
|
|
8
|
+
//
|
|
9
|
+
// - a task waiting for their approval sent at once: an agent is stopped until they answer
|
|
10
|
+
// - messages from agents naming them batched, at most one post per --every minutes
|
|
11
|
+
//
|
|
12
|
+
// Neither is sent while that person is in the room (the Control Room counts): they can see it.
|
|
13
|
+
//
|
|
14
|
+
// WHAT IS NEVER SENT: the room's link or full id. On an open relay the id is the key to the room's
|
|
15
|
+
// files, and a Slack channel is not the place for a key. Messages name the room by --title, or by
|
|
16
|
+
// the first characters of its id.
|
|
17
|
+
//
|
|
18
|
+
// IT MUST NOT LOOK ALIVE AND DELIVER NOTHING. It posts once when it starts and refuses to run if
|
|
19
|
+
// that post fails; it says once when it has lost the room and once when it is back.
|
|
20
|
+
//
|
|
21
|
+
// Error codes Q640-Q649 are this module's.
|
|
22
|
+
|
|
23
|
+
import fs from 'fs'
|
|
24
|
+
import os from 'os'
|
|
25
|
+
import path from 'path'
|
|
26
|
+
import crypto from 'crypto'
|
|
27
|
+
import { startSync, parseLink } from './sync.js'
|
|
28
|
+
import { makeNotifier, scrub } from './hive-webhook.js'
|
|
29
|
+
|
|
30
|
+
const oneLine = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t }
|
|
31
|
+
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
32
|
+
const LOST_AFTER = 5 * 60000
|
|
33
|
+
|
|
34
|
+
/** Does `text` address `name` (@Name, not part of a longer word)? @all counts. */
|
|
35
|
+
export function addresses(text, name) {
|
|
36
|
+
const t = String(text || '')
|
|
37
|
+
if (/(^|[^\w@])@all\b/i.test(t)) return true
|
|
38
|
+
return new RegExp(`(^|[^\\w@])@${esc(name)}(?![\\w-])`, 'i').test(t)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What has been sent, kept on disk so a restart does not send it again. */
|
|
42
|
+
export function stateFileFor(room, home = os.homedir()) {
|
|
43
|
+
const h = crypto.createHash('sha256').update(String(room)).digest('hex').slice(0, 16)
|
|
44
|
+
return path.join(home, '.hivecode', 'notify', `${h}.json`)
|
|
45
|
+
}
|
|
46
|
+
export function lockFileFor(room, home = os.homedir()) {
|
|
47
|
+
const h = crypto.createHash('sha256').update(String(room)).digest('hex').slice(0, 16)
|
|
48
|
+
return path.join(home, '.hivecode', 'notify', `${h}.lock`)
|
|
49
|
+
}
|
|
50
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' } }
|
|
51
|
+
|
|
52
|
+
/** One notifier per room. Created exclusively so two starting at the same time cannot double-post. */
|
|
53
|
+
export function takeLock(file) {
|
|
54
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
55
|
+
for (let i = 0; i < 2; i++) {
|
|
56
|
+
try {
|
|
57
|
+
const fd = fs.openSync(file, 'wx')
|
|
58
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }))
|
|
59
|
+
fs.closeSync(fd)
|
|
60
|
+
return () => { try { if (JSON.parse(fs.readFileSync(file, 'utf8')).pid === process.pid) fs.unlinkSync(file) } catch { } }
|
|
61
|
+
} catch (e) {
|
|
62
|
+
if (e.code !== 'EEXIST') throw e
|
|
63
|
+
let held = null
|
|
64
|
+
try { held = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { held = null }
|
|
65
|
+
if (held && held.pid && held.pid !== process.pid && alive(held.pid)) {
|
|
66
|
+
const err = new Error(`Q644 a notifier is already running for this room (process ${held.pid}, since ${held.at}). Use that one, or stop it first.`)
|
|
67
|
+
err.code = 'Q644'
|
|
68
|
+
throw err
|
|
69
|
+
}
|
|
70
|
+
try { fs.unlinkSync(file) } catch { }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const err = new Error(`Q644 could not take ${file}`)
|
|
74
|
+
err.code = 'Q644'
|
|
75
|
+
throw err
|
|
76
|
+
}
|
|
77
|
+
function loadSent(file) {
|
|
78
|
+
try { const j = JSON.parse(fs.readFileSync(file, 'utf8')); if (j && j.tasks) return j } catch { }
|
|
79
|
+
return { tasks: {} }
|
|
80
|
+
}
|
|
81
|
+
function saveSent(file, s) {
|
|
82
|
+
try {
|
|
83
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
84
|
+
const ids = Object.keys(s.tasks)
|
|
85
|
+
if (ids.length > 500) for (const id of ids.slice(0, ids.length - 500)) delete s.tasks[id]
|
|
86
|
+
fs.writeFileSync(file, JSON.stringify(s))
|
|
87
|
+
} catch { /* a notifier that cannot remember sends twice; it still sends */ }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The watching itself, on a live sync handle. `check()` is one pass; the CLI calls it on a timer,
|
|
92
|
+
* and tests call it with a clock of their own.
|
|
93
|
+
*/
|
|
94
|
+
export function makeWatch({ hive, forNames, title, send, now = () => Date.now(), every = 5, stateFile, log = () => {} }) {
|
|
95
|
+
const people = forNames.map((n) => String(n).trim()).filter(Boolean)
|
|
96
|
+
const lc = new Set(people.map((n) => n.toLowerCase()))
|
|
97
|
+
const tasks = hive.doc.getMap('tasks')
|
|
98
|
+
const owners = hive.doc.getMap('owners')
|
|
99
|
+
const chat = hive.doc.getArray('chat')
|
|
100
|
+
const sent = loadSent(stateFile)
|
|
101
|
+
let seenChat = chat.length // what was said before this started is not news
|
|
102
|
+
const buffer = []
|
|
103
|
+
let lastFlush = 0
|
|
104
|
+
let lostSince = 0
|
|
105
|
+
let toldLost = false
|
|
106
|
+
|
|
107
|
+
const here = (name) => (hive.members ? hive.members() : []).some((m) => String(m.name).toLowerCase() === name.toLowerCase() && m.kind === 'human')
|
|
108
|
+
const ownerOf = (t) => String(owners.get(t.to) || '')
|
|
109
|
+
|
|
110
|
+
function waiting() {
|
|
111
|
+
return [...tasks.values()].filter((t) => t && t.status === 'pending' && lc.has(ownerOf(t).toLowerCase()))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function check() {
|
|
115
|
+
const t = now()
|
|
116
|
+
// THE CONNECTION. Lost for five minutes is said once; back is said once.
|
|
117
|
+
const connected = !!(hive.connection ? hive.connection().connected : hive.provider && hive.provider.wsconnected)
|
|
118
|
+
if (!connected) {
|
|
119
|
+
if (!lostSince) lostSince = t
|
|
120
|
+
if (!toldLost && t - lostSince >= LOST_AFTER) {
|
|
121
|
+
toldLost = true
|
|
122
|
+
await send(`Hivecode lost ${title} ${Math.round((t - lostSince) / 60000)} minutes ago, so nothing from it can reach you here. It keeps trying to reconnect.`)
|
|
123
|
+
}
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (lostSince) {
|
|
127
|
+
if (toldLost) await send(`Hivecode is back in ${title}.`)
|
|
128
|
+
lostSince = 0
|
|
129
|
+
toldLost = false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A TASK WAITING FOR APPROVAL: at once, once per task.
|
|
133
|
+
for (const task of waiting()) {
|
|
134
|
+
const owner = ownerOf(task)
|
|
135
|
+
if (sent.tasks[task.id] || here(owner)) continue
|
|
136
|
+
const ok = await send(`Approval needed in ${title}: ${task.by} asked ${task.to} to "${oneLine(task.text, 300)}". ${task.to} waits until ${owner} approves or denies it in the Control Room.`)
|
|
137
|
+
if (ok) {
|
|
138
|
+
sent.tasks[task.id] = t
|
|
139
|
+
saveSent(stateFile, sent)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// MESSAGES NAMING THEM, from agents, while they are away: batched.
|
|
144
|
+
const all = chat.toArray()
|
|
145
|
+
if (all.length < seenChat) seenChat = 0 // the relay restarted and the room's history went with it
|
|
146
|
+
for (const m of all.slice(seenChat)) {
|
|
147
|
+
if (!m || m.kind !== 'ai') continue
|
|
148
|
+
for (const p of people) if (addresses(m.text, p) && !here(p)) { buffer.push({ by: m.by, to: p, text: m.text }); break }
|
|
149
|
+
}
|
|
150
|
+
seenChat = all.length
|
|
151
|
+
if (buffer.length && t - lastFlush >= every * 60000) {
|
|
152
|
+
const shown = buffer.slice()
|
|
153
|
+
const to = [...new Set(shown.map((b) => b.to))].join(', ')
|
|
154
|
+
const lines = shown.slice(0, 5).map((b) => `- ${b.by}: ${oneLine(b.text, 220)}`)
|
|
155
|
+
if (shown.length > 5) lines.push(`- and ${shown.length - 5} more`)
|
|
156
|
+
const ok = await send(`${shown.length} message${shown.length === 1 ? '' : 's'} for ${to} in ${title}:\n${lines.join('\n')}`)
|
|
157
|
+
if (ok) {
|
|
158
|
+
buffer.splice(0, shown.length)
|
|
159
|
+
lastFlush = t
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { check, waiting, buffered: () => buffer.length, sent }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- CLI -------------------------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
const USAGE = `usage:
|
|
170
|
+
npx -y hivecode-mcp@latest notify "<link>" --webhook <Slack or Discord webhook URL> --for <name>[,<name>]
|
|
171
|
+
|
|
172
|
+
--for <names> whose approvals and mentions to send (the people, not their agents)
|
|
173
|
+
--every <min> at most one post of messages per this many minutes (default 5)
|
|
174
|
+
--title <text> what to call the room in posts (default: the first characters of its id)
|
|
175
|
+
--name <name> the notifier's own name in the room (default Notifier)`
|
|
176
|
+
|
|
177
|
+
export async function main(argv, { log = console.log, logErr = console.error, fetchImpl = globalThis.fetch, home = os.homedir(), now = () => Date.now(), once = false } = {}) {
|
|
178
|
+
if (argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') { log(USAGE); return 0 }
|
|
179
|
+
const SWITCHES = new Set(['dry-run', 'once'])
|
|
180
|
+
const pos = []
|
|
181
|
+
const f = {}
|
|
182
|
+
for (let i = 0; i < argv.length; i++) {
|
|
183
|
+
const a = argv[i]
|
|
184
|
+
if (a.startsWith('--')) {
|
|
185
|
+
const k = a.slice(2)
|
|
186
|
+
if (SWITCHES.has(k)) f[k] = true
|
|
187
|
+
else f[k] = argv[++i] ?? ''
|
|
188
|
+
} else {
|
|
189
|
+
pos.push(a)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const link = pos[0] || ''
|
|
193
|
+
const { relay, room, token } = parseLink(link)
|
|
194
|
+
if (!relay || !room) { logErr(`Q640 "${link}" is not a room link (it looks like wss://relay|room-...).\n\n${USAGE}`); return 2 }
|
|
195
|
+
if (!f.webhook) { logErr(`Q641 say where to post, with --webhook <Slack or Discord incoming-webhook URL>.\n\n${USAGE}`); return 2 }
|
|
196
|
+
const forNames = String(f.for || '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
197
|
+
if (!forNames.length) { logErr(`Q642 say whose waits to report, with --for <name> (the person, as they appear in the room).\n\n${USAGE}`); return 2 }
|
|
198
|
+
const every = Number(f.every || 5)
|
|
199
|
+
if (!(every > 0)) { logErr('Q642 --every must be a positive number of minutes'); return 2 }
|
|
200
|
+
const title = f.title ? oneLine(f.title, 60) : `room ${room.slice(0, 8)}…`
|
|
201
|
+
const send = makeNotifier(f.webhook, { fetchImpl, log })
|
|
202
|
+
|
|
203
|
+
// FIRST, PROVE THE WEBHOOK WORKS. A notifier whose posts go nowhere is worse than none: the person
|
|
204
|
+
// believes they will be told.
|
|
205
|
+
if (send.kind === 'other' && send.host) log(`posting in Slack format to ${send.host}`)
|
|
206
|
+
const okSent = await send(`Hivecode will post here when an agent in ${title} is waiting on ${forNames.join(', ')}.`)
|
|
207
|
+
if (!okSent) { logErr('Q643 the webhook refused the first post (see above). Check the URL, then start again. Nothing is running.'); return 1 }
|
|
208
|
+
|
|
209
|
+
const lockFile = lockFileFor(room, home)
|
|
210
|
+
const releaseLock = (() => { try { return takeLock(lockFile) } catch (e) { logErr(e.message); return null } })()
|
|
211
|
+
if (!releaseLock) return 2
|
|
212
|
+
|
|
213
|
+
const dir = path.join(home, '.hivecode', 'notify', `room-${crypto.createHash('sha256').update(room).digest('hex').slice(0, 12)}`)
|
|
214
|
+
const hive = startSync({ relay, room, dir, name: f.name || 'Notifier', kind: 'ai', token: token || process.env.HIVE_TOKEN || '', syncFiles: false, passive: true, log: () => { } })
|
|
215
|
+
const w = makeWatch({ hive, forNames, title, send, now, every, stateFile: stateFileFor(room, home), log })
|
|
216
|
+
let synced = false
|
|
217
|
+
await new Promise((res) => { const t = setTimeout(res, 15000); hive.provider.on('sync', (s) => { if (s) { synced = true; clearTimeout(t); res() } }) })
|
|
218
|
+
if (!synced && !(hive.provider && hive.provider.wsconnected)) {
|
|
219
|
+
logErr(`NOTE: could not connect to relay at ${relay}. The room may be unreachable or the network down.`)
|
|
220
|
+
}
|
|
221
|
+
const members = (hive.members() || []).filter((m) => m.name !== (f.name || 'Notifier')).map((m) => `${m.name} (${m.kind})`)
|
|
222
|
+
const pending = w.waiting().length
|
|
223
|
+
log(`watching ${title}: ${members.length ? members.join(', ') : 'nobody here yet'}; ${pending} approval(s) waiting now. Ctrl+C stops it.`)
|
|
224
|
+
if (!members.length && !hive.doc.getArray('chat').length) log('NOTE: this room is empty -- no members and no chat. Is the link right?')
|
|
225
|
+
if (once || f['dry-run']) { await w.check(); hive.stop(); releaseLock(); return 0 }
|
|
226
|
+
const timer = setInterval(() => { w.check().catch((e) => log(`check failed: ${e.message}`)) }, 15000)
|
|
227
|
+
const quit = () => { clearInterval(timer); try { hive.stop() } catch { } try { releaseLock() } catch { } process.exit(0) }
|
|
228
|
+
process.on('SIGINT', quit)
|
|
229
|
+
process.on('SIGTERM', quit)
|
|
230
|
+
await w.check()
|
|
231
|
+
return new Promise(() => { }) // runs until stopped
|
|
232
|
+
}
|
|
233
|
+
|
package/hive-pr.js
CHANGED
|
@@ -37,6 +37,7 @@ import { spawnSync } from 'child_process'
|
|
|
37
37
|
import { fileURLToPath } from 'url'
|
|
38
38
|
import { startSync, parseLink } from './sync.js'
|
|
39
39
|
import { accountFor, verdict, explain, DIVERGED } from './hive-pr-staleness.js'
|
|
40
|
+
import { contentHash } from './substrate.js'
|
|
40
41
|
|
|
41
42
|
const argv = process.argv.slice(2)
|
|
42
43
|
const [LINK = '', NAME = 'hive-pr'] = argv.filter((a) => !a.startsWith('--'))
|
|
@@ -52,6 +53,7 @@ const DRY = has('dry-run')
|
|
|
52
53
|
const DO_PUSH = has('push') || has('pr')
|
|
53
54
|
const DO_PR = has('pr')
|
|
54
55
|
const ONLY = flag('only') ? flag('only').split(',').map((s) => s.trim()).filter(Boolean) : null
|
|
56
|
+
const PLAN_JSON = flag('plan-json')
|
|
55
57
|
|
|
56
58
|
function die(code, msg) { console.error(`${code} ${msg}`); process.exit(1) }
|
|
57
59
|
|
|
@@ -74,20 +76,8 @@ function gitRaw(args, opts = {}) {
|
|
|
74
76
|
return { code: r.status, out: r.stdout == null ? '' : r.stdout, err: (r.stderr || '').trim() }
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
export function authorsOf(receipts, verify) {
|
|
80
|
-
const ok = receipts.filter((r) => r && r.author && (!verify || verify(r)))
|
|
81
|
-
if (!ok.length) return { last: null, contributors: [], first: 0 }
|
|
82
|
-
const last = ok[ok.length - 1]
|
|
83
|
-
const seen = new Map()
|
|
84
|
-
for (const r of ok) if (!seen.has(r.author)) seen.set(r.author, { author: r.author, name: r.name || 'agent', at: r.at || 0 })
|
|
85
|
-
return {
|
|
86
|
-
last: { author: last.author, name: last.name || 'agent', at: last.at || 0 },
|
|
87
|
-
contributors: [...seen.values()],
|
|
88
|
-
first: Math.min(...ok.map((r) => r.at || 0)),
|
|
89
|
-
}
|
|
90
|
-
}
|
|
79
|
+
import { authorsOf } from './hive-authors.js'
|
|
80
|
+
export { authorsOf }
|
|
91
81
|
|
|
92
82
|
const mailOf = (name) => `${String(name).toLowerCase().replace(/[^a-z0-9._-]+/g, '-') || 'agent'}@agents.hivecode.local`
|
|
93
83
|
|
|
@@ -96,22 +86,165 @@ export function planCommits(files) {
|
|
|
96
86
|
const groups = new Map()
|
|
97
87
|
for (const f of files) {
|
|
98
88
|
if (!f.last) continue
|
|
99
|
-
const g = groups.get(f.last.author) || { author: f.last.author, name: f.last.name, at: f.first, files: [], others: new Map() }
|
|
89
|
+
const g = groups.get(f.last.author) || { author: f.last.author, name: f.last.name, at: f.first, files: [], others: new Map(), receipts: [] }
|
|
100
90
|
g.files.push(f)
|
|
101
91
|
g.at = Math.min(g.at, f.first)
|
|
92
|
+
if (Array.isArray(f.receipts)) g.receipts.push(...f.receipts)
|
|
102
93
|
for (const c of f.contributors) if (c.author !== f.last.author) g.others.set(c.author, c)
|
|
103
94
|
groups.set(f.last.author, g)
|
|
104
95
|
}
|
|
105
96
|
return [...groups.values()]
|
|
106
|
-
|
|
107
|
-
|
|
97
|
+
.map((g) => ({ ...g, others: [...g.others.values()] }))
|
|
98
|
+
.sort((a, b) => a.at - b.at || a.name.localeCompare(b.name))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function sanitizeIntent(raw) {
|
|
102
|
+
if (typeof raw !== 'string') return ''
|
|
103
|
+
// Collapse newlines and control characters to single spaces, and cap each at 200 chars
|
|
104
|
+
const clean = raw.replace(/[\x00-\x1f\x7f-\x9f\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim()
|
|
105
|
+
if (!clean) return ''
|
|
106
|
+
return clean.length > 200 ? clean.slice(0, 200).trim() : clean
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function truncateSubject(agent, intent, max = 72) {
|
|
110
|
+
const prefix = `${agent}: `
|
|
111
|
+
const full = `${prefix}${intent}`
|
|
112
|
+
if (full.length <= max) return full
|
|
113
|
+
const available = max - prefix.length - 3 // space for '...'
|
|
114
|
+
if (available <= 0) return full.slice(0, max)
|
|
115
|
+
const sliced = intent.slice(0, available)
|
|
116
|
+
const lastSpace = sliced.lastIndexOf(' ')
|
|
117
|
+
const cut = (lastSpace > 0) ? sliced.slice(0, lastSpace) : sliced
|
|
118
|
+
return `${prefix}${cut}...`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function buildPlanJson({ room, groups = [], orphans = [], identical = [], blocked = [], hashes = {} }) {
|
|
122
|
+
const skip = new Set(identical)
|
|
123
|
+
return {
|
|
124
|
+
room,
|
|
125
|
+
groups: groups.map((g) => {
|
|
126
|
+
const gFiles = (g.files || []).map((f) => (typeof f === 'string' ? f : f.rel)).filter((rel) => !skip.has(rel))
|
|
127
|
+
const allReceipts = []
|
|
128
|
+
if (Array.isArray(g.receipts)) allReceipts.push(...g.receipts)
|
|
129
|
+
for (const f of g.files || []) {
|
|
130
|
+
if (f && Array.isArray(f.receipts)) allReceipts.push(...f.receipts)
|
|
131
|
+
}
|
|
132
|
+
const seen = new Set()
|
|
133
|
+
const intents = []
|
|
134
|
+
for (const r of allReceipts) {
|
|
135
|
+
if (!r || typeof r.intent !== 'string') continue
|
|
136
|
+
const clean = sanitizeIntent(r.intent)
|
|
137
|
+
if (!clean || clean.toLowerCase() === 'edit') continue
|
|
138
|
+
if (!r.author || r.author === g.author) {
|
|
139
|
+
if (!seen.has(clean)) {
|
|
140
|
+
seen.add(clean)
|
|
141
|
+
intents.push(clean)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
name: g.name,
|
|
147
|
+
author: g.author,
|
|
148
|
+
files: gFiles,
|
|
149
|
+
intents,
|
|
150
|
+
}
|
|
151
|
+
}),
|
|
152
|
+
orphans: orphans.map((o) => (typeof o === 'string' ? o : o.rel)),
|
|
153
|
+
identical: [...identical],
|
|
154
|
+
blocked: [...blocked],
|
|
155
|
+
// The room's content hash of every file a group would write, as this mirror read it: what a
|
|
156
|
+
// hive_review must match, and how a watcher tells a changed room from a re-run of the same one.
|
|
157
|
+
hashes: Object.fromEntries(Object.entries(hashes || {}).filter(([rel]) => !skip.has(rel))),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const OWN_FILES = new Set(['.hive.json', '.hive-shadow.json', '.hive-shadow.json.tmp', '.hive-lock.json', '.hive-id.json', '.hive-mcp-session.json', '.hive-stop-hook.json'])
|
|
162
|
+
export const isOwnFile = (rel) => {
|
|
163
|
+
const b = path.basename(rel)
|
|
164
|
+
return OWN_FILES.has(b) || /^HIVE_[A-Z0-9_]+\.md$/i.test(b) || b.startsWith('.hive')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function isOrphan({ rel, last, roomText, branchText }) {
|
|
168
|
+
if (last) return false
|
|
169
|
+
if (isOwnFile(rel)) return false
|
|
170
|
+
if (branchText !== null && branchText !== undefined && branchText === roomText) return false
|
|
171
|
+
return true
|
|
108
172
|
}
|
|
109
173
|
|
|
110
174
|
export function commitMessage(g, room) {
|
|
111
175
|
const list = g.files.map((f) => f.rel).sort()
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
176
|
+
|
|
177
|
+
// Collect receipts up to held content across files in the group
|
|
178
|
+
const allReceipts = []
|
|
179
|
+
if (Array.isArray(g.receipts)) allReceipts.push(...g.receipts)
|
|
180
|
+
for (const f of g.files) {
|
|
181
|
+
if (Array.isArray(f.receipts)) allReceipts.push(...f.receipts)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Deduplicate and filter non-edit intents for the committing author
|
|
185
|
+
const seenAuthorIntents = new Set()
|
|
186
|
+
const authorIntents = []
|
|
187
|
+
|
|
188
|
+
// Deduplicate and filter non-edit intents for co-authors
|
|
189
|
+
const seenCoauthorIntents = new Set()
|
|
190
|
+
const coauthorIntents = []
|
|
191
|
+
|
|
192
|
+
const othersMap = new Map((g.others || []).map((o) => [o.author, o.name]))
|
|
193
|
+
|
|
194
|
+
for (const r of allReceipts) {
|
|
195
|
+
if (!r || typeof r.intent !== 'string') continue
|
|
196
|
+
const clean = sanitizeIntent(r.intent)
|
|
197
|
+
if (!clean || clean.toLowerCase() === 'edit') continue
|
|
198
|
+
|
|
199
|
+
if (!r.author || r.author === g.author) {
|
|
200
|
+
if (!seenAuthorIntents.has(clean)) {
|
|
201
|
+
seenAuthorIntents.add(clean)
|
|
202
|
+
authorIntents.push(clean)
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
const coauthorName = r.name || othersMap.get(r.author) || 'co-author'
|
|
206
|
+
const key = `${coauthorName}: ${clean}`
|
|
207
|
+
if (!seenCoauthorIntents.has(key)) {
|
|
208
|
+
seenCoauthorIntents.add(key)
|
|
209
|
+
coauthorIntents.push({ name: coauthorName, intent: clean })
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Subject: "<agent>: <first non-edit intent>" up to 72 chars, fallback to today's message
|
|
215
|
+
let head
|
|
216
|
+
if (authorIntents.length > 0) {
|
|
217
|
+
head = truncateSubject(g.name, authorIntents[0], 72)
|
|
218
|
+
} else {
|
|
219
|
+
head = list.length === 1 ? `${g.name}: ${list[0]}` : `${g.name}: ${list.length} files`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Body: lists intents and files if non-edit intents exist, fallback to today's message
|
|
223
|
+
const lines = [head, '']
|
|
224
|
+
if (authorIntents.length > 0 || coauthorIntents.length > 0) {
|
|
225
|
+
lines.push('Intents:')
|
|
226
|
+
for (const intent of authorIntents) {
|
|
227
|
+
lines.push(` - ${intent}`)
|
|
228
|
+
}
|
|
229
|
+
for (const ci of coauthorIntents) {
|
|
230
|
+
lines.push(` - ${ci.name}: ${ci.intent}`)
|
|
231
|
+
}
|
|
232
|
+
lines.push('')
|
|
233
|
+
lines.push('Files:')
|
|
234
|
+
for (const f of list) {
|
|
235
|
+
lines.push(` - ${f}`)
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
for (const f of list) {
|
|
239
|
+
lines.push(` - ${f}`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
lines.push('')
|
|
243
|
+
lines.push(`Room: ${room}`)
|
|
244
|
+
lines.push(`Hivecode-Author: ${g.author}`)
|
|
245
|
+
for (const o of g.others) {
|
|
246
|
+
lines.push(`Co-authored-by: ${o.name} <${mailOf(o.name)}>`)
|
|
247
|
+
}
|
|
115
248
|
return lines.join('\n')
|
|
116
249
|
}
|
|
117
250
|
|
|
@@ -206,12 +339,27 @@ if (isMain) {
|
|
|
206
339
|
const receipts = hive.provenanceOf(rel) || []
|
|
207
340
|
const audit = hive.verifyProvenanceOf(rel) || { bad: [] }
|
|
208
341
|
const badIdx = new Set((audit.bad || []).map((b) => b.i))
|
|
209
|
-
|
|
342
|
+
let roomText = null
|
|
343
|
+
try { roomText = fs.readFileSync(path.join(dir, rel), 'utf8') } catch { }
|
|
344
|
+
const a = authorsOf(receipts, (r) => !badIdx.has(receipts.indexOf(r)), roomText)
|
|
210
345
|
files.push({ rel, abs: path.join(dir, rel), ...a })
|
|
211
346
|
}
|
|
212
347
|
|
|
213
|
-
const
|
|
214
|
-
const
|
|
348
|
+
const base = flag('base') || git(['rev-parse', '--abbrev-ref', 'HEAD']).out || 'HEAD'
|
|
349
|
+
const attributed = []
|
|
350
|
+
const orphans = []
|
|
351
|
+
for (const f of files) {
|
|
352
|
+
if (f.last) {
|
|
353
|
+
attributed.push(f)
|
|
354
|
+
} else {
|
|
355
|
+
const sha = git(['rev-parse', `${base}:${f.rel}`])
|
|
356
|
+
const branchText = sha.code === 0 ? gitRaw(['cat-file', 'blob', sha.out]).out : null
|
|
357
|
+
const roomText = fs.readFileSync(f.abs, 'utf8')
|
|
358
|
+
if (isOrphan({ rel: f.rel, last: f.last, roomText, branchText })) {
|
|
359
|
+
orphans.push(f)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
215
363
|
const groups = planCommits(attributed)
|
|
216
364
|
|
|
217
365
|
console.log(`room ${room}: ${files.length} files, ${attributed.length} attributed, ${groups.length} agent(s)`)
|
|
@@ -221,11 +369,6 @@ if (isMain) {
|
|
|
221
369
|
}
|
|
222
370
|
if (orphans.length) console.log(` (${orphans.length} file(s) with no verified receipt -- not committed: ${orphans.map((o) => o.rel).join(', ')})`)
|
|
223
371
|
|
|
224
|
-
if (DRY) { console.log('\n--dry-run: nothing written'); return finish(0) }
|
|
225
|
-
if (!groups.length) { console.error('Q501 nothing attributed to commit'); return finish(1) }
|
|
226
|
-
|
|
227
|
-
const base = flag('base') || git(['rev-parse', '--abbrev-ref', 'HEAD']).out || 'HEAD'
|
|
228
|
-
|
|
229
372
|
// ── HIV-28: DO NOT WRITE OVER A BRANCH CHANGE WE CANNOT ACCOUNT FOR ──────────────────
|
|
230
373
|
//
|
|
231
374
|
// Measured 19 Sep 2026: this tool produced a PR with three clean per-agent commits, real git
|
|
@@ -251,7 +394,39 @@ if (isMain) {
|
|
|
251
394
|
const v = verdict(decisions)
|
|
252
395
|
if (v.identical.length) console.log(` (${v.identical.length} file(s) already identical on ${base} -- nothing to write)`)
|
|
253
396
|
|
|
397
|
+
function writePlanJson() {
|
|
398
|
+
if (!PLAN_JSON) return
|
|
399
|
+
const identicalRels = (v && v.identical) ? v.identical.map((d) => d.rel) : []
|
|
400
|
+
const blockedRels = (v && v.blocked) ? v.blocked.map((d) => d.rel) : []
|
|
401
|
+
const plan = buildPlanJson({
|
|
402
|
+
room,
|
|
403
|
+
groups,
|
|
404
|
+
orphans,
|
|
405
|
+
identical: identicalRels,
|
|
406
|
+
blocked: blockedRels,
|
|
407
|
+
hashes: Object.fromEntries(groups.flatMap((g) => g.files || []).map((x) => { try { return [x.rel, contentHash(fs.readFileSync(x.abs, 'utf8'))] } catch { return null } }).filter(Boolean)),
|
|
408
|
+
})
|
|
409
|
+
try {
|
|
410
|
+
fs.mkdirSync(path.dirname(path.resolve(PLAN_JSON)), { recursive: true })
|
|
411
|
+
fs.writeFileSync(PLAN_JSON, JSON.stringify(plan, null, 2), 'utf8')
|
|
412
|
+
} catch (err) {
|
|
413
|
+
console.error(`warning: could not write --plan-json to ${PLAN_JSON}: ${err.message}`)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (DRY) {
|
|
418
|
+
writePlanJson()
|
|
419
|
+
console.log('\n--dry-run: nothing written')
|
|
420
|
+
return finish(0)
|
|
421
|
+
}
|
|
422
|
+
if (!groups.length) {
|
|
423
|
+
writePlanJson()
|
|
424
|
+
console.error('Q501 nothing attributed to commit')
|
|
425
|
+
return finish(1)
|
|
426
|
+
}
|
|
427
|
+
|
|
254
428
|
if (!v.ok) {
|
|
429
|
+
writePlanJson()
|
|
255
430
|
const lastTouch = (rel) => git(['log', '-1', '--format=%h %an %ad %s', '--date=short', base, '--', rel]).out
|
|
256
431
|
console.error('')
|
|
257
432
|
console.error(explain(v.blocked, lastTouch))
|
|
@@ -324,6 +499,7 @@ if (isMain) {
|
|
|
324
499
|
if (r.status !== 0) { console.error(`Q505 gh pr create failed: ${(r.stderr || '').trim()}`); return finish(1) }
|
|
325
500
|
console.log((r.stdout || '').trim())
|
|
326
501
|
}
|
|
502
|
+
writePlanJson()
|
|
327
503
|
finish(0)
|
|
328
504
|
}
|
|
329
505
|
|
|
@@ -334,4 +510,4 @@ if (isMain) {
|
|
|
334
510
|
process.exit(code)
|
|
335
511
|
}, 400)
|
|
336
512
|
}
|
|
337
|
-
}
|
|
513
|
+
}
|