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-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
- /** The authors of a file, newest-landed first, from VERIFIED receipts only.
78
- * An unverifiable receipt is not evidence of authorship, so it is dropped rather than trusted. */
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
- .map((g) => ({ ...g, others: [...g.others.values()] }))
107
- .sort((a, b) => a.at - b.at || a.name.localeCompare(b.name))
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
- const head = list.length === 1 ? `${g.name}: ${list[0]}` : `${g.name}: ${list.length} files`
113
- const lines = [head, '', ...list.map((x) => ` - ${x}`), '', `Room: ${room}`, `Hivecode-Author: ${g.author}`]
114
- for (const o of g.others) lines.push(`Co-authored-by: ${o.name} <${mailOf(o.name)}>`)
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
- const a = authorsOf(receipts, (r) => !badIdx.has(receipts.indexOf(r)))
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 attributed = files.filter((f) => f.last)
214
- const orphans = files.filter((f) => !f.last)
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
+ }