hivecode-mcp 0.14.1 → 0.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/HIVE_RULES.md +98 -98
  2. package/LICENSE +21 -21
  3. package/README.md +421 -421
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -310
  6. package/docs/GITHUB_WORKFLOW.md +394 -394
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -157
  9. package/docs/QUICKSTART.md +266 -266
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +474 -474
  12. package/docs/TROUBLESHOOTING.md +106 -105
  13. package/hive-activity.js +336 -336
  14. package/hive-authors.js +46 -44
  15. package/hive-await.js +230 -230
  16. package/hive-branch.js +674 -607
  17. package/hive-busy.js +88 -88
  18. package/hive-coord.js +110 -110
  19. package/hive-gate.js +135 -135
  20. package/hive-github.js +3 -1
  21. package/hive-hook-install.js +179 -179
  22. package/hive-instructions.js +74 -74
  23. package/hive-land.js +709 -691
  24. package/hive-listen.js +81 -81
  25. package/hive-mailbox.js +520 -520
  26. package/hive-mcp-http.js +591 -591
  27. package/hive-mcp.js +92 -12
  28. package/hive-mux-proto.js +58 -58
  29. package/hive-mux.js +354 -351
  30. package/hive-notify.js +234 -233
  31. package/hive-obligations.js +247 -247
  32. package/hive-poll-proto.js +50 -50
  33. package/hive-pr-staleness.js +189 -189
  34. package/hive-pr.js +637 -512
  35. package/hive-privacy.js +327 -327
  36. package/hive-receipts.js +233 -233
  37. package/hive-relay-health.js +234 -234
  38. package/hive-repos.js +430 -430
  39. package/hive-room-key.js +210 -210
  40. package/hive-room-mode.js +1761 -1668
  41. package/hive-room-watcher.js +623 -561
  42. package/hive-secrets.js +89 -89
  43. package/hive-session.js +114 -114
  44. package/hive-setup.js +560 -560
  45. package/hive-slack-agent.js +408 -408
  46. package/hive-slack.js +2417 -2367
  47. package/hive-stdio.js +172 -172
  48. package/hive-stop-hook.js +293 -293
  49. package/hive-throttle.js +118 -118
  50. package/hive-time.js +71 -71
  51. package/hive-transport.js +617 -578
  52. package/hive-update.js +58 -58
  53. package/hive-version.js +110 -110
  54. package/hive-webhook.js +55 -54
  55. package/icr-merge.js +58 -58
  56. package/icr.js +856 -851
  57. package/lang-brace.js +305 -305
  58. package/lang-js.js +695 -651
  59. package/lang-json.js +211 -211
  60. package/lang-python.js +588 -588
  61. package/lang-ruby.js +230 -230
  62. package/lang-toml.js +136 -136
  63. package/lang-yaml.js +183 -183
  64. package/limits.js +413 -413
  65. package/package.json +11 -3
  66. package/reconcile.cjs +621 -621
  67. package/slack-app-manifest-agent.json +61 -61
  68. package/slack-app-manifest-inbound.json +36 -36
  69. package/slack-app-manifest.json +27 -27
  70. package/substrate.js +252 -252
  71. package/sync.js +434 -60
  72. package/token.js +201 -201
package/hive-stop-hook.js CHANGED
@@ -1,293 +1,293 @@
1
- #!/usr/bin/env node
2
- /**
3
- * THE ANTI-IDLE GATE.
4
- *
5
- * A rule an agent is told to follow is advice. This is the same rule as a lock.
6
- *
7
- * THE PROBLEM. hive_wait exists so an agent never goes unreachable: block until someone needs
8
- * you, do the work, block again. It is in the tool description and it is now in the system
9
- * prompt (hive-instructions.js). Both are still instructions, and an agent that decides it is
10
- * finished simply emits a final answer instead of another tool call. Its process is alive, its
11
- * socket is up, and it is deaf. Everyone waiting on it waits forever, and nothing tells them.
12
- *
13
- * You cannot fix that with better wording, for the same reason a claim board that only warns
14
- * does not stop a write: it is enforcement or it is a suggestion.
15
- *
16
- * WHAT THIS IS. A Claude Code `Stop` hook. Claude Code runs it when the main agent is about to
17
- * end its turn. Returning {"decision":"block","reason":...} makes Claude Code refuse to stop and
18
- * feed `reason` back as the next input. The agent does not get a vote. So an agent that tries to
19
- * go idle while it has unread mail or unfinished accepted work is simply not allowed to.
20
- *
21
- * WHY IT READS DISK AND NOT THE RELAY. This runs on every turn boundary and has a hard timeout.
22
- * A network round trip inside that budget would make the hook the least reliable thing in the
23
- * session. It does not need one: the sync client already mirrors the room to HIVE_CHAT.md and
24
- * HIVE_TASKS.md on local disk. Reading those is microseconds, works with the relay unreachable,
25
- * and cannot itself desync the room because it only ever reads.
26
- *
27
- * IT BLOCKS ON THREE THINGS, and the middle one is the subtle one:
28
- *
29
- * - unread mail addressed to you
30
- * - a question you DID read and never answered ("read" is not "answered": the cursor advances
31
- * the moment a message is handed over, so an agent can absorb a direct question mid-work and
32
- * end its turn having left someone hanging, with nothing unread to block on)
33
- * - an accepted task still open
34
- *
35
- * FOUR GUARDS AGAINST TRAPPING THE AGENT IN A LOOP. A hook that blocks forever is worse than
36
- * no hook -- it burns the user's tokens and they cannot get their agent back:
37
- *
38
- * 1. stop_hook_active. Claude Code sets this when the stop it is asking about is ALREADY the
39
- * result of a previous block. We always allow then, so a block is never immediately
40
- * re-applied on top of itself.
41
- * 2. The watermark advances even when we block. A given message can cause exactly one block,
42
- * ever. If the agent ignores it, that is the human's problem to see, not ours to enforce.
43
- * 3. `nudged` -- the same one-block-per-message rule extended across BOTH reasons, so a
44
- * message cannot charge once as unread mail and again as an unanswered question. Keyed on
45
- * sender+timestamp, so a genuinely new question still gets its own single nudge.
46
- * 4. MAX_TASK_BLOCKS. Unlike a message, an open task stays open, so it would re-block on every
47
- * turn until completed -- including when it CANNOT be completed. After a few consecutive
48
- * task-only blocks we stand down and let the agent stop.
49
- *
50
- * Any failure at all -- no session, unreadable file, malformed JSON, unexpected shape -- exits 0
51
- * and allows the stop. The hook may make an agent more reliable; it must never make a session
52
- * unusable.
53
- *
54
- * Install with: npx -y -p hivecode-mcp@latest hivecode-hook install
55
- */
56
- import fs from 'fs'
57
- import path from 'path'
58
- import { mentionsName, unansweredFor } from './hive-mailbox.js'
59
- import { isArmed, listenCommand, listenHow, listenOrder } from './hive-listen.js'
60
- import { readAgents, agentForHook } from './hive-session.js'
61
-
62
- // Consecutive turns we will block purely because a task is still open, before standing down.
63
- // Messages are not subject to this: the watermark already makes each one block at most once.
64
- const MAX_TASK_BLOCKS = 3
65
-
66
- const SESSION_FILE = '.hive-mcp-session.json' // written by hive-mcp.js on join
67
- const STATE_FILE = '.hive-stop-hook.json' // ours: watermark, task blocks, questions already nudged
68
-
69
- // A chat line: "- 19:07:27 Eve (ai): text". Anything else is a continuation of the message
70
- // above it (messages may contain newlines) or a header comment.
71
- const CHAT_LINE = /^- (\d{2}:\d{2}:\d{2})\s+(.+?) \((human|ai|system)\): ([\s\S]*)$/
72
- // A task line: "- [accepted] tmtofacav4mf Ganesh -> PM: do the thing (by Ganesh)"
73
- const TASK_LINE = /^- \[(\w+)\]\s+(\S+)\s+(.+?) -> (.+?): ([\s\S]*)$/
74
-
75
- const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null } }
76
-
77
- /** Fold continuation lines into their message so a multi-line post counts once. */
78
- export function parseChat(src) {
79
- const out = []
80
- for (const line of String(src || '').split(/\r?\n/)) {
81
- const m = CHAT_LINE.exec(line)
82
- if (m) out.push({ at: m[1], by: m[2], kind: m[3], text: m[4] })
83
- else if (out.length && line.trim() && !line.startsWith('#')) out[out.length - 1].text += '\n' + line
84
- }
85
- return out
86
- }
87
-
88
- /** Accepted tasks pointed at `me` that nobody has marked done. */
89
- export function openTasksFor(src, me) {
90
- const out = []
91
- for (const line of String(src || '').split(/\r?\n/)) {
92
- const m = TASK_LINE.exec(line)
93
- // `done` and `completed` both appear in the wild depending on how the task was closed;
94
- // anything that is not explicitly accepted-and-open is not work we should block on.
95
- if (m && m[1] === 'accepted' && m[4].trim().toLowerCase() === String(me).toLowerCase()) {
96
- out.push({ id: m[2], from: m[3], text: m[5] })
97
- }
98
- }
99
- return out
100
- }
101
-
102
- // A TURN THAT ENDS WITH NO LISTENER RUNNING IS A DEAF AGENT NOBODY KNOWS IS DEAF (4 Oct 2026). The
103
- // order to start one was said once, on the first quiet hive_wait, and an agent had to remember it
104
- // after every wake; agents forget. So this gate also asks "is a listener running for you?"
105
- // (hive-listen.js) before the turn ends. It rides on the SAME block as any mail -- one block per
106
- // stop, never a second -- because a stop that was already blocked once is let through
107
- // (stop_hook_active), and a listener order queued behind a mail block would never be said. An
108
- // agent that ignores it MAX_LISTEN_BLOCKS turns running is let go: it cannot or will not, and a gate
109
- // that nags is a gate the user switches off. Seeing a listener running resets the count.
110
- const MAX_LISTEN_BLOCKS = 3
111
-
112
- /**
113
- * The whole decision, as a pure function of what is on disk, so the test can drive it without a
114
- * Claude Code session, a relay, or a room.
115
- *
116
- * `listening`: true (a listener is running), false (none, and one can be started), or null (not
117
- * applicable: no link on disk, a room that checks tokens, an app that is not woken by a background
118
- * task). `listenLine`: the order to give when it is false.
119
- *
120
- * Returns { block: boolean, reason?: string, seen: number, taskBlocks: number, listenBlocks: number }.
121
- */
122
- export function decide(o) {
123
- const d = obligations(o)
124
- const { listening = null, listenLine = '' } = o
125
- const listenBlocks = Number(o.listenBlocks) || 0
126
- if (listening !== false || !listenLine) return { ...d, listenBlocks: listening ? 0 : listenBlocks }
127
- if (listenBlocks >= MAX_LISTEN_BLOCKS) return { ...d, listenBlocks }
128
- return { ...d, block: true, reason: d.block ? `${d.reason}\n\n${listenLine}` : listenLine, listenBlocks: listenBlocks + 1 }
129
- }
130
-
131
- function obligations({ chat, tasks, me, seen, taskBlocks, nudged = [] }) {
132
- const msgs = parseChat(chat)
133
- const nowSeen = msgs.length
134
- // A cursor past the end means this is a different (rebuilt or switched) room -- start over
135
- // rather than treating the whole history as unread and blocking on all of it.
136
- const from = seen > nowSeen ? 0 : seen
137
- const fresh = msgs.slice(from)
138
-
139
- const mail = fresh.filter((m) =>
140
- m.kind !== 'system' &&
141
- String(m.by).toLowerCase() !== String(me).toLowerCase() && // never block on our own post
142
- mentionsName(m.text, me))
143
-
144
- if (mail.length) {
145
- const quoted = mail.slice(0, 5).map((m) => ` ${m.by}: ${m.text.replace(/\s+/g, ' ').slice(0, 220)}`)
146
- const more = mail.length > 5 ? `\n ... and ${mail.length - 5} more` : ''
147
- return {
148
- block: true, seen: nowSeen, taskBlocks: 0,
149
- // Blocking here also spends this message's ONE nudge, so the debt branch below cannot
150
- // charge for the same message a second time. One block per message, whatever the reason.
151
- nudged: [...nudged, ...mail.map((m) => m.by + '|' + m.at)].slice(-200),
152
- reason: `You are in a Hivecode room and ${mail.length} message${mail.length > 1 ? 's' : ''} ` +
153
- `addressed to you arrived while you were working:\n\n${quoted.join('\n')}${more}\n\n` +
154
- `Do not stop -- they are waiting on a reply from you. Answer with hive_say (put @Name on ` +
155
- `it), then go back to hive_wait. hive_wait is your idle state; ending your turn makes you ` +
156
- `unreachable and nobody in the room finds out.`,
157
- }
158
- }
159
-
160
- // A DEBT IS NOT THE SAME AS UNREAD MAIL, and this is the gap the watermark alone leaves. The
161
- // cursor advances the instant a message is handed over, so an agent can read a direct
162
- // question, get absorbed in the work, and end its turn having left someone hanging -- with
163
- // nothing unread and nothing to block on. The person waiting has no way to find that out.
164
- //
165
- // ONE NUDGE PER QUESTION, EVER -- the same rule mail already follows, and it has to be the
166
- // same rule. A debt survives until it is answered, so anything weaker than remembering which
167
- // ones we already raised means the identical question blocks turn after turn, and a gate that
168
- // nags is a gate the user switches off. Keyed on sender+timestamp rather than on sender, so a
169
- // NEW question from someone we have already nudged about still gets its own one nudge.
170
- const owed = unansweredFor(msgs, me)
171
- const unnudged = owed.filter((o) => !nudged.includes(o.from + '|' + o.at))
172
- if (unnudged.length) {
173
- const quoted = unnudged.slice(0, 3).map((o) => ` ${o.from}: ${o.text.replace(/\s+/g, ' ').slice(0, 200)}`)
174
- return {
175
- block: true, seen: nowSeen, taskBlocks,
176
- // Recorded BEFORE returning, exactly as the watermark is, so a question cannot block a
177
- // second time even if whatever happens next goes wrong. Capped so the state file cannot
178
- // grow without bound in a long-lived room.
179
- nudged: [...nudged, ...unnudged.map((o) => o.from + '|' + o.at)].slice(-200),
180
- reason: `You are in a Hivecode room and ${unnudged.length} participant${unnudged.length > 1 ? 's are' : ' is'} ` +
181
- `still waiting on a reply from you:\n\n${quoted.join('\n')}\n\n` +
182
- `Answer with hive_say and name them. "Not yet — I am on X, I will come back to you" is a ` +
183
- `complete answer; silence is the one thing they cannot act on, and nothing tells them you ` +
184
- `stopped. Then call hive_wait.`,
185
- }
186
- }
187
-
188
- const open = openTasksFor(tasks, me)
189
- if (open.length && taskBlocks < MAX_TASK_BLOCKS) {
190
- const quoted = open.slice(0, 3).map((t) => ` [${t.id}] from ${t.from}: ${t.text.replace(/\s+/g, ' ').slice(0, 220)}`)
191
- return {
192
- block: true, seen: nowSeen, taskBlocks: taskBlocks + 1, nudged,
193
- reason: `You are in a Hivecode room with ${open.length} accepted task${open.length > 1 ? 's' : ''} ` +
194
- `still open:\n\n${quoted.join('\n')}\n\n` +
195
- `Finish the work and call hive_complete with the task id, or say why you are blocked with ` +
196
- `hive_say (@ the person who assigned it). Then call hive_wait. If you genuinely cannot ` +
197
- `proceed, say so in the room first -- do not just stop, nobody would learn that you had.`,
198
- }
199
- }
200
-
201
- // Standing down on something we have already blocked on MAX_TASK_BLOCKS times is not the same
202
- // as it being resolved, so hold the counter rather than resetting it while it is still there.
203
- // Debts and tasks share the counter deliberately: they are the same kind of obligation, and
204
- // two separate counters would let an agent be pinned for 2x MAX_TASK_BLOCKS turns.
205
- return { block: false, seen: nowSeen, taskBlocks: open.length ? taskBlocks : 0, nudged }
206
- }
207
-
208
- /** Walk up from `start` looking for the session file; the hook's cwd is usually but not always
209
- * the folder the MCP server was launched in. That folder can have started several agents (two
210
- * Claude Code sessions in one repo), so the record is the one for THIS hook's conversation, and
211
- * none at all when that cannot be told (hive-session.js). */
212
- function findSession(start, hook = {}) {
213
- let dir = path.resolve(start)
214
- for (let i = 0; i < 4; i++) {
215
- const p = path.join(dir, SESSION_FILE)
216
- if (fs.existsSync(p)) return { root: dir, session: agentForHook(readAgents(p), hook) }
217
- const up = path.dirname(dir)
218
- if (up === dir) break
219
- dir = up
220
- }
221
- return null
222
- }
223
-
224
- const APP = (/--app=(\S+)/.exec(process.argv.slice(2).join(' ')) || [])[1] || 'claude-code'
225
-
226
- async function main() {
227
- const allow = () => { if (APP === 'antigravity') process.stdout.write(JSON.stringify({ decision: 'stop' })) }
228
- try {
229
- let input = ''
230
- for await (const chunk of process.stdin) input += chunk
231
- const hook = (() => { try { return JSON.parse(input) } catch { return {} } })()
232
-
233
- // GUARD 1: this stop is already the product of a block. Never stack another on it.
234
- if (hook.stop_hook_active || Number(hook.executionNum) > 0) return allow()
235
-
236
- const found = findSession(hook.cwd || (Array.isArray(hook.workspacePaths) && hook.workspacePaths[0]) || process.cwd(), hook)
237
- if (!found || !found.session || !found.session.dir || !found.session.name) return allow() // not in a room
238
-
239
- const { root, session } = found
240
- // A passive participant never answers and the room waits on nobody passive: it owes no reply,
241
- // no task, and no listener, so there is nothing to hold its turn open for.
242
- if (session.passive) return allow()
243
- const dir = path.isAbsolute(session.dir) ? session.dir : path.join(root, session.dir)
244
- const read = (f) => { try { return fs.readFileSync(path.join(dir, f), 'utf8') } catch { return '' } }
245
-
246
- const chat = read('HIVE_CHAT.md')
247
- if (!chat) return allow() // room folder not mirrored yet; nothing to be sure about, so allow
248
-
249
- const statePath = path.join(root, STATE_FILE)
250
- const prev = readJson(statePath) || {}
251
- // Keyed by room: a folder rejoined against a different room must not inherit a watermark from
252
- // unrelated history -- the same reasoning as the persisted sync ancestor being room-keyed.
253
- const key = String(session.room || session.dir)
254
- const st = prev[key] || { seen: 0, taskBlocks: 0, nudged: [] }
255
-
256
- // Is a listener running for this agent? Asked only where the agent could start one: an app that
257
- // is woken when a background task ends, the room's link on disk, and a room that does not check
258
- // tokens (the agent's shell does not hold the MCP server's token, so a listener could not join).
259
- const cfg = readJson(path.join(dir, '.hive.json')) || {}
260
- const how = listenHow(APP)
261
- let listening = null, listenLine = ''
262
- if (how && cfg.relay && cfg.room && !session.authed && !cfg.token) {
263
- listening = isArmed({ name: session.name, room: cfg.room, session: String(hook.session_id || hook.conversationId || process.env.ANTIGRAVITY_CONVERSATION_ID || '') })
264
- listenLine = listenOrder(how, listenCommand({ relay: cfg.relay, room: cfg.room, name: session.name, app: APP }))
265
- }
266
-
267
- const d = decide({
268
- chat, tasks: read('HIVE_TASKS.md'), me: session.name,
269
- seen: Number(st.seen) || 0, taskBlocks: Number(st.taskBlocks) || 0,
270
- nudged: Array.isArray(st.nudged) ? st.nudged : [],
271
- listening, listenLine, listenBlocks: Number(st.listenBlocks) || 0,
272
- })
273
-
274
- // GUARD 2: write the watermark BEFORE returning the block, so this message can never block a
275
- // second time even if what happens next goes wrong.
276
- try {
277
- prev[key] = { seen: d.seen, taskBlocks: d.taskBlocks, nudged: d.nudged || [], listenBlocks: d.listenBlocks, at: Date.now() }
278
- fs.writeFileSync(statePath, JSON.stringify(prev, null, 2))
279
- } catch { /* a watermark we cannot persist is a reason to allow, never to fail */ }
280
-
281
- // Antigravity's Stop hook blocks with "continue" (its engine's own schema: stop | continue |
282
- // block); Claude Code's with "block". Which app runs this is said in its hook entry (--app).
283
- if (d.block) process.stdout.write(JSON.stringify({ decision: APP === 'antigravity' ? 'continue' : 'block', reason: d.reason }))
284
- else allow()
285
- } catch {
286
- allow()
287
- }
288
- }
289
-
290
- // Only run when executed as the hook, so the test can import decide()/parseChat() cleanly.
291
- if (process.argv[1] && /hive-stop-hook\.js$/.test(process.argv[1])) {
292
- main().catch(() => { }).finally(() => process.exit(0))
293
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * THE ANTI-IDLE GATE.
4
+ *
5
+ * A rule an agent is told to follow is advice. This is the same rule as a lock.
6
+ *
7
+ * THE PROBLEM. hive_wait exists so an agent never goes unreachable: block until someone needs
8
+ * you, do the work, block again. It is in the tool description and it is now in the system
9
+ * prompt (hive-instructions.js). Both are still instructions, and an agent that decides it is
10
+ * finished simply emits a final answer instead of another tool call. Its process is alive, its
11
+ * socket is up, and it is deaf. Everyone waiting on it waits forever, and nothing tells them.
12
+ *
13
+ * You cannot fix that with better wording, for the same reason a claim board that only warns
14
+ * does not stop a write: it is enforcement or it is a suggestion.
15
+ *
16
+ * WHAT THIS IS. A Claude Code `Stop` hook. Claude Code runs it when the main agent is about to
17
+ * end its turn. Returning {"decision":"block","reason":...} makes Claude Code refuse to stop and
18
+ * feed `reason` back as the next input. The agent does not get a vote. So an agent that tries to
19
+ * go idle while it has unread mail or unfinished accepted work is simply not allowed to.
20
+ *
21
+ * WHY IT READS DISK AND NOT THE RELAY. This runs on every turn boundary and has a hard timeout.
22
+ * A network round trip inside that budget would make the hook the least reliable thing in the
23
+ * session. It does not need one: the sync client already mirrors the room to HIVE_CHAT.md and
24
+ * HIVE_TASKS.md on local disk. Reading those is microseconds, works with the relay unreachable,
25
+ * and cannot itself desync the room because it only ever reads.
26
+ *
27
+ * IT BLOCKS ON THREE THINGS, and the middle one is the subtle one:
28
+ *
29
+ * - unread mail addressed to you
30
+ * - a question you DID read and never answered ("read" is not "answered": the cursor advances
31
+ * the moment a message is handed over, so an agent can absorb a direct question mid-work and
32
+ * end its turn having left someone hanging, with nothing unread to block on)
33
+ * - an accepted task still open
34
+ *
35
+ * FOUR GUARDS AGAINST TRAPPING THE AGENT IN A LOOP. A hook that blocks forever is worse than
36
+ * no hook -- it burns the user's tokens and they cannot get their agent back:
37
+ *
38
+ * 1. stop_hook_active. Claude Code sets this when the stop it is asking about is ALREADY the
39
+ * result of a previous block. We always allow then, so a block is never immediately
40
+ * re-applied on top of itself.
41
+ * 2. The watermark advances even when we block. A given message can cause exactly one block,
42
+ * ever. If the agent ignores it, that is the human's problem to see, not ours to enforce.
43
+ * 3. `nudged` -- the same one-block-per-message rule extended across BOTH reasons, so a
44
+ * message cannot charge once as unread mail and again as an unanswered question. Keyed on
45
+ * sender+timestamp, so a genuinely new question still gets its own single nudge.
46
+ * 4. MAX_TASK_BLOCKS. Unlike a message, an open task stays open, so it would re-block on every
47
+ * turn until completed -- including when it CANNOT be completed. After a few consecutive
48
+ * task-only blocks we stand down and let the agent stop.
49
+ *
50
+ * Any failure at all -- no session, unreadable file, malformed JSON, unexpected shape -- exits 0
51
+ * and allows the stop. The hook may make an agent more reliable; it must never make a session
52
+ * unusable.
53
+ *
54
+ * Install with: npx -y -p hivecode-mcp@latest hivecode-hook install
55
+ */
56
+ import fs from 'fs'
57
+ import path from 'path'
58
+ import { mentionsName, unansweredFor } from './hive-mailbox.js'
59
+ import { isArmed, listenCommand, listenHow, listenOrder } from './hive-listen.js'
60
+ import { readAgents, agentForHook } from './hive-session.js'
61
+
62
+ // Consecutive turns we will block purely because a task is still open, before standing down.
63
+ // Messages are not subject to this: the watermark already makes each one block at most once.
64
+ const MAX_TASK_BLOCKS = 3
65
+
66
+ const SESSION_FILE = '.hive-mcp-session.json' // written by hive-mcp.js on join
67
+ const STATE_FILE = '.hive-stop-hook.json' // ours: watermark, task blocks, questions already nudged
68
+
69
+ // A chat line: "- 19:07:27 Eve (ai): text". Anything else is a continuation of the message
70
+ // above it (messages may contain newlines) or a header comment.
71
+ const CHAT_LINE = /^- (\d{2}:\d{2}:\d{2})\s+(.+?) \((human|ai|system)\): ([\s\S]*)$/
72
+ // A task line: "- [accepted] tmtofacav4mf Ganesh -> PM: do the thing (by Ganesh)"
73
+ const TASK_LINE = /^- \[(\w+)\]\s+(\S+)\s+(.+?) -> (.+?): ([\s\S]*)$/
74
+
75
+ const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null } }
76
+
77
+ /** Fold continuation lines into their message so a multi-line post counts once. */
78
+ export function parseChat(src) {
79
+ const out = []
80
+ for (const line of String(src || '').split(/\r?\n/)) {
81
+ const m = CHAT_LINE.exec(line)
82
+ if (m) out.push({ at: m[1], by: m[2], kind: m[3], text: m[4] })
83
+ else if (out.length && line.trim() && !line.startsWith('#')) out[out.length - 1].text += '\n' + line
84
+ }
85
+ return out
86
+ }
87
+
88
+ /** Accepted tasks pointed at `me` that nobody has marked done. */
89
+ export function openTasksFor(src, me) {
90
+ const out = []
91
+ for (const line of String(src || '').split(/\r?\n/)) {
92
+ const m = TASK_LINE.exec(line)
93
+ // `done` and `completed` both appear in the wild depending on how the task was closed;
94
+ // anything that is not explicitly accepted-and-open is not work we should block on.
95
+ if (m && m[1] === 'accepted' && m[4].trim().toLowerCase() === String(me).toLowerCase()) {
96
+ out.push({ id: m[2], from: m[3], text: m[5] })
97
+ }
98
+ }
99
+ return out
100
+ }
101
+
102
+ // A TURN THAT ENDS WITH NO LISTENER RUNNING IS A DEAF AGENT NOBODY KNOWS IS DEAF (4 Oct 2026). The
103
+ // order to start one was said once, on the first quiet hive_wait, and an agent had to remember it
104
+ // after every wake; agents forget. So this gate also asks "is a listener running for you?"
105
+ // (hive-listen.js) before the turn ends. It rides on the SAME block as any mail -- one block per
106
+ // stop, never a second -- because a stop that was already blocked once is let through
107
+ // (stop_hook_active), and a listener order queued behind a mail block would never be said. An
108
+ // agent that ignores it MAX_LISTEN_BLOCKS turns running is let go: it cannot or will not, and a gate
109
+ // that nags is a gate the user switches off. Seeing a listener running resets the count.
110
+ const MAX_LISTEN_BLOCKS = 3
111
+
112
+ /**
113
+ * The whole decision, as a pure function of what is on disk, so the test can drive it without a
114
+ * Claude Code session, a relay, or a room.
115
+ *
116
+ * `listening`: true (a listener is running), false (none, and one can be started), or null (not
117
+ * applicable: no link on disk, a room that checks tokens, an app that is not woken by a background
118
+ * task). `listenLine`: the order to give when it is false.
119
+ *
120
+ * Returns { block: boolean, reason?: string, seen: number, taskBlocks: number, listenBlocks: number }.
121
+ */
122
+ export function decide(o) {
123
+ const d = obligations(o)
124
+ const { listening = null, listenLine = '' } = o
125
+ const listenBlocks = Number(o.listenBlocks) || 0
126
+ if (listening !== false || !listenLine) return { ...d, listenBlocks: listening ? 0 : listenBlocks }
127
+ if (listenBlocks >= MAX_LISTEN_BLOCKS) return { ...d, listenBlocks }
128
+ return { ...d, block: true, reason: d.block ? `${d.reason}\n\n${listenLine}` : listenLine, listenBlocks: listenBlocks + 1 }
129
+ }
130
+
131
+ function obligations({ chat, tasks, me, seen, taskBlocks, nudged = [] }) {
132
+ const msgs = parseChat(chat)
133
+ const nowSeen = msgs.length
134
+ // A cursor past the end means this is a different (rebuilt or switched) room -- start over
135
+ // rather than treating the whole history as unread and blocking on all of it.
136
+ const from = seen > nowSeen ? 0 : seen
137
+ const fresh = msgs.slice(from)
138
+
139
+ const mail = fresh.filter((m) =>
140
+ m.kind !== 'system' &&
141
+ String(m.by).toLowerCase() !== String(me).toLowerCase() && // never block on our own post
142
+ mentionsName(m.text, me))
143
+
144
+ if (mail.length) {
145
+ const quoted = mail.slice(0, 5).map((m) => ` ${m.by}: ${m.text.replace(/\s+/g, ' ').slice(0, 220)}`)
146
+ const more = mail.length > 5 ? `\n ... and ${mail.length - 5} more` : ''
147
+ return {
148
+ block: true, seen: nowSeen, taskBlocks: 0,
149
+ // Blocking here also spends this message's ONE nudge, so the debt branch below cannot
150
+ // charge for the same message a second time. One block per message, whatever the reason.
151
+ nudged: [...nudged, ...mail.map((m) => m.by + '|' + m.at)].slice(-200),
152
+ reason: `You are in a Hivecode room and ${mail.length} message${mail.length > 1 ? 's' : ''} ` +
153
+ `addressed to you arrived while you were working:\n\n${quoted.join('\n')}${more}\n\n` +
154
+ `Do not stop -- they are waiting on a reply from you. Answer with hive_say (put @Name on ` +
155
+ `it), then go back to hive_wait. hive_wait is your idle state; ending your turn makes you ` +
156
+ `unreachable and nobody in the room finds out.`,
157
+ }
158
+ }
159
+
160
+ // A DEBT IS NOT THE SAME AS UNREAD MAIL, and this is the gap the watermark alone leaves. The
161
+ // cursor advances the instant a message is handed over, so an agent can read a direct
162
+ // question, get absorbed in the work, and end its turn having left someone hanging -- with
163
+ // nothing unread and nothing to block on. The person waiting has no way to find that out.
164
+ //
165
+ // ONE NUDGE PER QUESTION, EVER -- the same rule mail already follows, and it has to be the
166
+ // same rule. A debt survives until it is answered, so anything weaker than remembering which
167
+ // ones we already raised means the identical question blocks turn after turn, and a gate that
168
+ // nags is a gate the user switches off. Keyed on sender+timestamp rather than on sender, so a
169
+ // NEW question from someone we have already nudged about still gets its own one nudge.
170
+ const owed = unansweredFor(msgs, me)
171
+ const unnudged = owed.filter((o) => !nudged.includes(o.from + '|' + o.at))
172
+ if (unnudged.length) {
173
+ const quoted = unnudged.slice(0, 3).map((o) => ` ${o.from}: ${o.text.replace(/\s+/g, ' ').slice(0, 200)}`)
174
+ return {
175
+ block: true, seen: nowSeen, taskBlocks,
176
+ // Recorded BEFORE returning, exactly as the watermark is, so a question cannot block a
177
+ // second time even if whatever happens next goes wrong. Capped so the state file cannot
178
+ // grow without bound in a long-lived room.
179
+ nudged: [...nudged, ...unnudged.map((o) => o.from + '|' + o.at)].slice(-200),
180
+ reason: `You are in a Hivecode room and ${unnudged.length} participant${unnudged.length > 1 ? 's are' : ' is'} ` +
181
+ `still waiting on a reply from you:\n\n${quoted.join('\n')}\n\n` +
182
+ `Answer with hive_say and name them. "Not yet — I am on X, I will come back to you" is a ` +
183
+ `complete answer; silence is the one thing they cannot act on, and nothing tells them you ` +
184
+ `stopped. Then call hive_wait.`,
185
+ }
186
+ }
187
+
188
+ const open = openTasksFor(tasks, me)
189
+ if (open.length && taskBlocks < MAX_TASK_BLOCKS) {
190
+ const quoted = open.slice(0, 3).map((t) => ` [${t.id}] from ${t.from}: ${t.text.replace(/\s+/g, ' ').slice(0, 220)}`)
191
+ return {
192
+ block: true, seen: nowSeen, taskBlocks: taskBlocks + 1, nudged,
193
+ reason: `You are in a Hivecode room with ${open.length} accepted task${open.length > 1 ? 's' : ''} ` +
194
+ `still open:\n\n${quoted.join('\n')}\n\n` +
195
+ `Finish the work and call hive_complete with the task id, or say why you are blocked with ` +
196
+ `hive_say (@ the person who assigned it). Then call hive_wait. If you genuinely cannot ` +
197
+ `proceed, say so in the room first -- do not just stop, nobody would learn that you had.`,
198
+ }
199
+ }
200
+
201
+ // Standing down on something we have already blocked on MAX_TASK_BLOCKS times is not the same
202
+ // as it being resolved, so hold the counter rather than resetting it while it is still there.
203
+ // Debts and tasks share the counter deliberately: they are the same kind of obligation, and
204
+ // two separate counters would let an agent be pinned for 2x MAX_TASK_BLOCKS turns.
205
+ return { block: false, seen: nowSeen, taskBlocks: open.length ? taskBlocks : 0, nudged }
206
+ }
207
+
208
+ /** Walk up from `start` looking for the session file; the hook's cwd is usually but not always
209
+ * the folder the MCP server was launched in. That folder can have started several agents (two
210
+ * Claude Code sessions in one repo), so the record is the one for THIS hook's conversation, and
211
+ * none at all when that cannot be told (hive-session.js). */
212
+ function findSession(start, hook = {}) {
213
+ let dir = path.resolve(start)
214
+ for (let i = 0; i < 4; i++) {
215
+ const p = path.join(dir, SESSION_FILE)
216
+ if (fs.existsSync(p)) return { root: dir, session: agentForHook(readAgents(p), hook) }
217
+ const up = path.dirname(dir)
218
+ if (up === dir) break
219
+ dir = up
220
+ }
221
+ return null
222
+ }
223
+
224
+ const APP = (/--app=(\S+)/.exec(process.argv.slice(2).join(' ')) || [])[1] || 'claude-code'
225
+
226
+ async function main() {
227
+ const allow = () => { if (APP === 'antigravity') process.stdout.write(JSON.stringify({ decision: 'stop' })) }
228
+ try {
229
+ let input = ''
230
+ for await (const chunk of process.stdin) input += chunk
231
+ const hook = (() => { try { return JSON.parse(input) } catch { return {} } })()
232
+
233
+ // GUARD 1: this stop is already the product of a block. Never stack another on it.
234
+ if (hook.stop_hook_active || Number(hook.executionNum) > 0) return allow()
235
+
236
+ const found = findSession(hook.cwd || (Array.isArray(hook.workspacePaths) && hook.workspacePaths[0]) || process.cwd(), hook)
237
+ if (!found || !found.session || !found.session.dir || !found.session.name) return allow() // not in a room
238
+
239
+ const { root, session } = found
240
+ // A passive participant never answers and the room waits on nobody passive: it owes no reply,
241
+ // no task, and no listener, so there is nothing to hold its turn open for.
242
+ if (session.passive) return allow()
243
+ const dir = path.isAbsolute(session.dir) ? session.dir : path.join(root, session.dir)
244
+ const read = (f) => { try { return fs.readFileSync(path.join(dir, f), 'utf8') } catch { return '' } }
245
+
246
+ const chat = read('HIVE_CHAT.md')
247
+ if (!chat) return allow() // room folder not mirrored yet; nothing to be sure about, so allow
248
+
249
+ const statePath = path.join(root, STATE_FILE)
250
+ const prev = readJson(statePath) || {}
251
+ // Keyed by room: a folder rejoined against a different room must not inherit a watermark from
252
+ // unrelated history -- the same reasoning as the persisted sync ancestor being room-keyed.
253
+ const key = String(session.room || session.dir)
254
+ const st = prev[key] || { seen: 0, taskBlocks: 0, nudged: [] }
255
+
256
+ // Is a listener running for this agent? Asked only where the agent could start one: an app that
257
+ // is woken when a background task ends, the room's link on disk, and a room that does not check
258
+ // tokens (the agent's shell does not hold the MCP server's token, so a listener could not join).
259
+ const cfg = readJson(path.join(dir, '.hive.json')) || {}
260
+ const how = listenHow(APP)
261
+ let listening = null, listenLine = ''
262
+ if (how && cfg.relay && cfg.room && !session.authed && !cfg.token) {
263
+ listening = isArmed({ name: session.name, room: cfg.room, session: String(hook.session_id || hook.conversationId || process.env.ANTIGRAVITY_CONVERSATION_ID || '') })
264
+ listenLine = listenOrder(how, listenCommand({ relay: cfg.relay, room: cfg.room, name: session.name, app: APP }))
265
+ }
266
+
267
+ const d = decide({
268
+ chat, tasks: read('HIVE_TASKS.md'), me: session.name,
269
+ seen: Number(st.seen) || 0, taskBlocks: Number(st.taskBlocks) || 0,
270
+ nudged: Array.isArray(st.nudged) ? st.nudged : [],
271
+ listening, listenLine, listenBlocks: Number(st.listenBlocks) || 0,
272
+ })
273
+
274
+ // GUARD 2: write the watermark BEFORE returning the block, so this message can never block a
275
+ // second time even if what happens next goes wrong.
276
+ try {
277
+ prev[key] = { seen: d.seen, taskBlocks: d.taskBlocks, nudged: d.nudged || [], listenBlocks: d.listenBlocks, at: Date.now() }
278
+ fs.writeFileSync(statePath, JSON.stringify(prev, null, 2))
279
+ } catch { /* a watermark we cannot persist is a reason to allow, never to fail */ }
280
+
281
+ // Antigravity's Stop hook blocks with "continue" (its engine's own schema: stop | continue |
282
+ // block); Claude Code's with "block". Which app runs this is said in its hook entry (--app).
283
+ if (d.block) process.stdout.write(JSON.stringify({ decision: APP === 'antigravity' ? 'continue' : 'block', reason: d.reason }))
284
+ else allow()
285
+ } catch {
286
+ allow()
287
+ }
288
+ }
289
+
290
+ // Only run when executed as the hook, so the test can import decide()/parseChat() cleanly.
291
+ if (process.argv[1] && /hive-stop-hook\.js$/.test(process.argv[1])) {
292
+ main().catch(() => { }).finally(() => process.exit(0))
293
+ }