hivecode-mcp 0.12.2 → 0.13.0

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 (62) hide show
  1. package/HIVE_RULES.md +11 -40
  2. package/LICENSE +21 -21
  3. package/README.md +421 -167
  4. package/core.js +91 -91
  5. package/docs/ALWAYS_ON.md +310 -0
  6. package/docs/GITHUB_WORKFLOW.md +334 -0
  7. package/docs/HIVE_RULES.md +41 -41
  8. package/docs/MCP.md +157 -118
  9. package/docs/QUICKSTART.md +266 -0
  10. package/docs/SELF_HOSTING.md +256 -256
  11. package/docs/SLACK_SETUP.md +450 -0
  12. package/docs/TROUBLESHOOTING.md +87 -0
  13. package/hive-activity.js +336 -310
  14. package/hive-await.js +207 -0
  15. package/hive-branch.js +686 -0
  16. package/hive-busy.js +88 -88
  17. package/hive-coord.js +110 -110
  18. package/hive-gate.js +135 -135
  19. package/hive-github.js +1904 -0
  20. package/hive-hook-install.js +179 -103
  21. package/hive-instructions.js +74 -66
  22. package/hive-listen.js +81 -0
  23. package/hive-mailbox.js +520 -386
  24. package/hive-mcp-http.js +580 -0
  25. package/hive-mcp.js +579 -137
  26. package/hive-mux-proto.js +58 -58
  27. package/hive-mux.js +350 -350
  28. package/hive-notify.js +233 -0
  29. package/hive-obligations.js +247 -247
  30. package/hive-pr-staleness.js +189 -189
  31. package/hive-pr.js +350 -0
  32. package/hive-privacy.js +327 -288
  33. package/hive-receipts.js +233 -233
  34. package/hive-relay-health.js +230 -225
  35. package/hive-room-key.js +209 -0
  36. package/hive-secrets.js +89 -0
  37. package/hive-session.js +114 -0
  38. package/hive-setup.js +558 -445
  39. package/hive-slack-agent.js +396 -0
  40. package/hive-slack.js +1809 -0
  41. package/hive-stdio.js +172 -168
  42. package/hive-stop-hook.js +293 -241
  43. package/hive-throttle.js +118 -118
  44. package/hive-time.js +71 -0
  45. package/hive-update.js +58 -58
  46. package/hive-version.js +110 -59
  47. package/hive-webhook.js +54 -0
  48. package/icr-merge.js +58 -58
  49. package/icr.js +851 -745
  50. package/lang-json.js +211 -211
  51. package/lang-ruby.js +230 -230
  52. package/lang-toml.js +136 -136
  53. package/lang-yaml.js +183 -183
  54. package/limits.js +413 -394
  55. package/package.json +189 -164
  56. package/reconcile.cjs +614 -477
  57. package/slack-app-manifest-agent.json +61 -0
  58. package/slack-app-manifest-inbound.json +36 -0
  59. package/slack-app-manifest.json +27 -0
  60. package/substrate.js +252 -252
  61. package/sync.js +1956 -177
  62. package/grm-link.cjs +0 -141
package/hive-receipts.js CHANGED
@@ -1,233 +1,233 @@
1
- // hive-receipts.js — did the message actually reach anybody, or did it only look like it did?
2
- //
3
- // THE DEFECT THIS EXISTS FOR, from this project's own history:
4
- //
5
- // "29 messages @-mentioned me today. hive_wait has surfaced a handful."
6
- // "Those messages land in HIVE_CHAT.md and look delivered -- they just may never reach the
7
- // recipient's context. The convention looks like it works, which is the worst property a
8
- // coordination protocol can have."
9
- //
10
- // `hive_say` returns "sent to @Builder: ..." the instant the message enters the LOCAL Yjs array.
11
- // It says that while the socket is down. It says that when @Builder crashed ten minutes ago. It
12
- // says that when @Builder is looping without ever calling hive_wait. Three different failures,
13
- // one cheerful word, and the sender has no way to tell them apart.
14
- //
15
- // WHAT THE EVIDENCE IS, HERE. Agent Relay verifies a delivery by watching the terminal echo the
16
- // keystrokes back (crates/broker/src/broker/delivery_verification.rs). There is no terminal here,
17
- // so copying the mechanism would be cargo cult. The equivalent evidence in Hivecode is the one
18
- // thing that only moves when a message is actually handed into an agent's context:
19
- //
20
- // THE READ CURSOR IS THE ECHO.
21
- //
22
- // A mailbox cursor advances only inside `box.take()`, which is what hands messages to the agent.
23
- // So "their cursor passed the slot my message sits in" means the message was in the payload that
24
- // agent received. Today that cursor is written to a LOCAL session file, where nobody else can see
25
- // it. Publishing it into the room is the whole change.
26
- //
27
- // FOUR STATES, AND THE FOURTH IS THE POINT. Agent Relay has three (Success / Unverified /
28
- // Failed) and the load-bearing one is Unverified -- an unconfirmed delivery must never be counted
29
- // as a confirmed one. Their comment: "Unverified neither speeds up nor backs off the throttle,
30
- // but breaks the consecutive-success streak so unverified deliveries never drive the delay down."
31
- //
32
- // The same rule here, phrased for this system:
33
- //
34
- // READ their cursor passed it. Evidence it landed.
35
- // UNREAD they publish cursors, and theirs is behind it. Evidence it did NOT.
36
- // UNKNOWN they publish no cursor at all. NO evidence either way -- and it must never be
37
- // counted as READ. A Telegram bridge, a human's browser and an older build all
38
- // land here, so silence from this state means nothing at all.
39
- // UNREACHABLE they are not in the room, or WE are offline. It is not going to arrive.
40
- //
41
- // UNKNOWN is the honest state that a two-state design (delivered / not) cannot express, and
42
- // collapsing it into either neighbour is how a receipt system starts lying. `countDelivered`
43
- // exists precisely so that no caller can accidentally sum UNKNOWN into the good number.
44
- //
45
- // NO STORED INDEX. A Yjs array index is not stable: two peers pushing concurrently converge in a
46
- // deterministic order that need not be the order either one observed, so a message's index can
47
- // move after it was recorded. Receipts are therefore DERIVED at query time by locating the
48
- // message in the converged array -- the same choice `unansweredFor` made in hive-mailbox.js, for
49
- // the same reason: nothing to migrate and nothing that can drift.
50
-
51
- // @-parsing is imported, never re-implemented. A second hand-written copy of "who was named in
52
- // this text" is exactly the shape Agent Relay wrote up in learnings/duplicated-wire-contract.md
53
- // and exactly the shape that already bit this repo once as a duplicated reconcile(): two
54
- // descriptions of one thing, only one of which is ever checked against reality.
55
- import { mentionedNames, mentionsAll } from './hive-mailbox.js'
56
-
57
- export const READ = 'READ'
58
- export const UNREAD = 'UNREAD'
59
- export const UNKNOWN = 'UNKNOWN'
60
- export const UNREACHABLE = 'UNREACHABLE'
61
-
62
- /** Only these count as delivered. Anything else is an absence of evidence, not evidence. */
63
- export const DELIVERED_STATES = [READ]
64
-
65
- const lc = (x) => String(x == null ? '' : x).toLowerCase()
66
-
67
- /**
68
- * Publish my read position into the room.
69
- *
70
- * `at` is a count of messages consumed, so it only ever moves forward. It is clamped against
71
- * going BACKWARDS because a rejoin can hand a session a stale persisted cursor, and a receipt
72
- * that un-reads a message somebody already answered is worse than no receipt at all.
73
- */
74
- export function publishCursor(cursors, name, at, now = Date.now) {
75
- if (!cursors || !name) return null
76
- const prev = cursors.get(String(name))
77
- const next = Math.max(0, Number(at) || 0)
78
- const highest = Math.max(next, (prev && Number(prev.at)) || 0)
79
- const rec = { at: highest, ts: now(), ver: 1 }
80
- // Writing an identical value would still be a CRDT update, waking every observer in the room
81
- // on every tool call for no information. Only a real advance is published.
82
- if (prev && prev.at === highest) return prev
83
- cursors.set(String(name), rec)
84
- return rec
85
- }
86
-
87
- /** Case-insensitive, because @Builder and @builder are one agent everywhere else in this system. */
88
- export function cursorOf(cursors, name) {
89
- if (!cursors) return null
90
- const direct = cursors.get(String(name))
91
- if (direct) return direct
92
- for (const [k, v] of cursors.entries()) if (lc(k) === lc(name)) return v
93
- return null
94
- }
95
-
96
- /**
97
- * Where a message sits in the converged chat array, or -1.
98
- *
99
- * Identity is (by, at, text) rather than an index: the triple survives reordering, and the index
100
- * does not. Searched from the end because a receipt is almost always asked about something said
101
- * recently, and the room's history can be thousands of lines.
102
- */
103
- export function findIndex(chat, msg) {
104
- if (!Array.isArray(chat) || !msg) return -1
105
- for (let i = chat.length - 1; i >= 0; i--) {
106
- const m = chat[i]
107
- if (m && m.by === msg.by && m.at === msg.at && m.text === msg.text) return i
108
- }
109
- return -1
110
- }
111
-
112
- /**
113
- * The state of ONE message for ONE recipient.
114
- *
115
- * Order matters. `connected === false` outranks everything, because if our own socket is down the
116
- * message has not left this process and nothing we believe about the other side is worth saying.
117
- * Absence outranks a stale cursor for the same reason it does in hive-busy.js: it is the more
118
- * actionable of the two.
119
- */
120
- export function stateFor({ cursors, name, index, present = true, connected = true }) {
121
- if (!connected) return { name, state: UNREACHABLE, why: 'this client is offline — the message is in your local copy only and has not left this machine' }
122
- if (!present) return { name, state: UNREACHABLE, why: `@${name} is not in the room, so nobody is going to read it` }
123
- if (index < 0) return { name, state: UNKNOWN, why: 'that message is not in the room log — it may not have synced' }
124
- const cur = cursorOf(cursors, name)
125
- if (!cur) return { name, state: UNKNOWN, why: `@${name} publishes no read position (an older build, a bridge, or a human client) — silence from them means nothing either way` }
126
- // A cursor of N means N messages consumed, i.e. indexes 0..N-1. So the message at `index` has
127
- // been handed over exactly when the cursor is strictly greater than it.
128
- if (Number(cur.at) > index) return { name, state: READ, at: cur.ts, why: `@${name} has read it` }
129
- return { name, state: UNREAD, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is ${index + 1 - Number(cur.at)} message(s) behind and has NOT been handed this one` }
130
- }
131
-
132
- /**
133
- * The state of ONE message for ONE recipient, ASKED AS A DIFFERENT QUESTION.
134
- *
135
- * `stateFor` above answers "should I wait for a reply from them right now?", so absence outranks
136
- * a cursor: an agent that read your message and then left still cannot answer you, and blocking on
137
- * them is the failure that function exists to prevent. That is correct, it is deliberate, and
138
- * tests/receipts-test.mjs D1 pins it in capital letters.
139
- *
140
- * This one answers a different question: "did this message ever land?" — and there the cursor
141
- * outranks absence, because A READ IS A FACT ABOUT THE PAST AND LOGGING OFF CANNOT UNDO IT.
142
- *
143
- * WHY TWO FUNCTIONS AND NOT A FLAG. A boolean that silently changes what the returned STRING means
144
- * is how this class of bug is born: a caller that forgets it gets a confident answer to a question
145
- * it did not ask. Two names cannot be confused by omission.
146
- *
147
- * WHY THIS ORDER, AND IT IS THE WHOLE DESIGN:
148
- * - `!cur` is checked BEFORE `!present`. A human in a browser, a Telegram bridge and every older
149
- * build publish no cursor at all. Reaching `!present` first would render them UNREACHABLE with
150
- * the claim "they left without reading it" — a claim we cannot support, because we have no
151
- * evidence either way. That is precisely what UNKNOWN exists to express, and folding it into
152
- * UNREACHABLE would make every human in the room read as a delivery failure forever.
153
- * - `!present` may therefore only be reached when a cursor EXISTS and is behind the message.
154
- * Only then is "they left without reading it" a measurement rather than a guess.
155
- * - `connected` is absent on purpose. The cursors map is LOCAL. Our own socket being down right
156
- * now says nothing about whether a three-day-old message was read; that was recorded and is
157
- * already here. Gate this on connectivity and the whole transcript blanks to UNKNOWN during
158
- * every reconnect — exactly when a human is reading it to work out what happened.
159
- *
160
- * Built by DeepMind-Agent (Gemini, in its own container) in room `night`, 20 Sep 2026, against a
161
- * design argued in the room over four rounds. Tests are antigravity1's, written before the code
162
- * existed and verified red first: tests/transcript-receipts-test.mjs.
163
- */
164
- export function transcriptStateFor({ cursors, name, index, present = true }) {
165
- if (index < 0) return { name, state: UNKNOWN, why: 'that message is not in the room log — it may not have synced' }
166
- const cur = cursorOf(cursors, name)
167
- if (!cur) return { name, state: UNKNOWN, why: `@${name} publishes no read position (an older build, a bridge, or a human client) — silence from them means nothing either way` }
168
- // A cursor of N means N messages consumed, i.e. indexes 0..N-1. So the message at `index` has
169
- // been handed over exactly when the cursor is STRICTLY greater than it. A cursor sitting exactly
170
- // at `index` means NOT read; an off-by-one here is a receipt that lies quietly.
171
- if (Number(cur.at) > index) return { name, state: READ, at: cur.ts, why: `@${name} has read it` }
172
- // Only reachable when a cursor exists and is behind the message.
173
- if (!present) return { name, state: UNREACHABLE, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is not in the room, and left with cursor behind this message` }
174
- return { name, state: UNREAD, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is ${index + 1 - Number(cur.at)} message(s) behind and has NOT been handed this one` }
175
- }
176
-
177
- /** Only READ counts. Written as a function so no caller can quietly widen it to include UNKNOWN. */
178
- export const countDelivered = (states) => states.filter((s) => DELIVERED_STATES.includes(s.state)).length
179
-
180
- /**
181
- * Every message I addressed to somebody, with per-recipient state.
182
- *
183
- * Unaddressed messages are skipped on purpose: they land in the ambient digest, where agents are
184
- * instructed to skip them, so a receipt for one would be measuring something nobody promised.
185
- * `@all` is skipped for the same reason a broadcast creates no reply debt in hive-mailbox.js --
186
- * one message obliging eight receipts is the storm, not the signal.
187
- */
188
- export function receipts({ chat, cursors, me, present = () => true, connected = true, limit = 10 }) {
189
- const list = Array.isArray(chat) ? chat : []
190
- const out = []
191
- for (let i = list.length - 1; i >= 0 && out.length < limit; i--) {
192
- const m = list[i]
193
- if (!m || lc(m.by) !== lc(me)) continue
194
- if (mentionsAll(m.text)) continue
195
- const named = mentionedNames(m.text).filter((n) => lc(n) !== 'all' && lc(n) !== lc(me))
196
- if (!named.length) continue
197
- out.push({
198
- at: m.at,
199
- text: String(m.text || '').slice(0, 120),
200
- to: named.map((n) => stateFor({ cursors, name: n, index: i, present: present(n), connected })),
201
- })
202
- }
203
- return out.reverse()
204
- }
205
-
206
- const MARK = { [READ]: '✓', [UNREAD]: '…', [UNKNOWN]: '?', [UNREACHABLE]: '✗' }
207
-
208
- /** One line per message. The marks make a column a human can scan; the words carry the meaning. */
209
- export function describe(r) {
210
- return `${r.at} ${r.to.map((s) => `${MARK[s.state] || '?'} ${s.name}=${s.state}`).join(' ')} "${r.text}"`
211
- }
212
-
213
- /**
214
- * What hive_say should say instead of "sent".
215
- *
216
- * ONE FAILURE ONLY, AND THAT IS DELIBERATE. At send time delivery is not knowable -- the message
217
- * was created a millisecond ago -- so the single thing worth saying is the one that is already
218
- * decided and is currently reported as success: our own socket is down.
219
- *
220
- * It says nothing about an ABSENT recipient even though that is equally decidable, because
221
- * `describeRecipient` in hive-busy.js already says it ("@X is NOT in the room — nobody will read
222
- * that") and a second hand-written copy of that sentence is precisely the defect class Agent
223
- * Relay wrote up in learnings/duplicated-wire-contract.md: two descriptions of one fact, only one
224
- * of which anybody keeps current. One producer, one description.
225
- *
226
- * On success it returns the empty string. A reassuring word at send time IS the defect.
227
- */
228
- export function sendWarning({ connected }) {
229
- if (connected) return ''
230
- return 'NOT SENT — this client is OFFLINE. The message is in your local copy only; it has not ' +
231
- 'reached the relay and no one else can see it. It will go out by itself when the connection ' +
232
- 'heals (do NOT call hive_join, that tears down the session). Until then, treat it as unsent.'
233
- }
1
+ // hive-receipts.js — did the message actually reach anybody, or did it only look like it did?
2
+ //
3
+ // THE DEFECT THIS EXISTS FOR, from this project's own history:
4
+ //
5
+ // "29 messages @-mentioned me today. hive_wait has surfaced a handful."
6
+ // "Those messages land in HIVE_CHAT.md and look delivered -- they just may never reach the
7
+ // recipient's context. The convention looks like it works, which is the worst property a
8
+ // coordination protocol can have."
9
+ //
10
+ // `hive_say` returns "sent to @Builder: ..." the instant the message enters the LOCAL Yjs array.
11
+ // It says that while the socket is down. It says that when @Builder crashed ten minutes ago. It
12
+ // says that when @Builder is looping without ever calling hive_wait. Three different failures,
13
+ // one cheerful word, and the sender has no way to tell them apart.
14
+ //
15
+ // WHAT THE EVIDENCE IS, HERE. Agent Relay verifies a delivery by watching the terminal echo the
16
+ // keystrokes back (crates/broker/src/broker/delivery_verification.rs). There is no terminal here,
17
+ // so copying the mechanism would be cargo cult. The equivalent evidence in Hivecode is the one
18
+ // thing that only moves when a message is actually handed into an agent's context:
19
+ //
20
+ // THE READ CURSOR IS THE ECHO.
21
+ //
22
+ // A mailbox cursor advances only inside `box.take()`, which is what hands messages to the agent.
23
+ // So "their cursor passed the slot my message sits in" means the message was in the payload that
24
+ // agent received. Today that cursor is written to a LOCAL session file, where nobody else can see
25
+ // it. Publishing it into the room is the whole change.
26
+ //
27
+ // FOUR STATES, AND THE FOURTH IS THE POINT. Agent Relay has three (Success / Unverified /
28
+ // Failed) and the load-bearing one is Unverified -- an unconfirmed delivery must never be counted
29
+ // as a confirmed one. Their comment: "Unverified neither speeds up nor backs off the throttle,
30
+ // but breaks the consecutive-success streak so unverified deliveries never drive the delay down."
31
+ //
32
+ // The same rule here, phrased for this system:
33
+ //
34
+ // READ their cursor passed it. Evidence it landed.
35
+ // UNREAD they publish cursors, and theirs is behind it. Evidence it did NOT.
36
+ // UNKNOWN they publish no cursor at all. NO evidence either way -- and it must never be
37
+ // counted as READ. A Telegram bridge, a human's browser and an older build all
38
+ // land here, so silence from this state means nothing at all.
39
+ // UNREACHABLE they are not in the room, or WE are offline. It is not going to arrive.
40
+ //
41
+ // UNKNOWN is the honest state that a two-state design (delivered / not) cannot express, and
42
+ // collapsing it into either neighbour is how a receipt system starts lying. `countDelivered`
43
+ // exists precisely so that no caller can accidentally sum UNKNOWN into the good number.
44
+ //
45
+ // NO STORED INDEX. A Yjs array index is not stable: two peers pushing concurrently converge in a
46
+ // deterministic order that need not be the order either one observed, so a message's index can
47
+ // move after it was recorded. Receipts are therefore DERIVED at query time by locating the
48
+ // message in the converged array -- the same choice `unansweredFor` made in hive-mailbox.js, for
49
+ // the same reason: nothing to migrate and nothing that can drift.
50
+
51
+ // @-parsing is imported, never re-implemented. A second hand-written copy of "who was named in
52
+ // this text" is exactly the shape Agent Relay wrote up in learnings/duplicated-wire-contract.md
53
+ // and exactly the shape that already bit this repo once as a duplicated reconcile(): two
54
+ // descriptions of one thing, only one of which is ever checked against reality.
55
+ import { mentionedNames, mentionsAll } from './hive-mailbox.js'
56
+
57
+ export const READ = 'READ'
58
+ export const UNREAD = 'UNREAD'
59
+ export const UNKNOWN = 'UNKNOWN'
60
+ export const UNREACHABLE = 'UNREACHABLE'
61
+
62
+ /** Only these count as delivered. Anything else is an absence of evidence, not evidence. */
63
+ export const DELIVERED_STATES = [READ]
64
+
65
+ const lc = (x) => String(x == null ? '' : x).toLowerCase()
66
+
67
+ /**
68
+ * Publish my read position into the room.
69
+ *
70
+ * `at` is a count of messages consumed, so it only ever moves forward. It is clamped against
71
+ * going BACKWARDS because a rejoin can hand a session a stale persisted cursor, and a receipt
72
+ * that un-reads a message somebody already answered is worse than no receipt at all.
73
+ */
74
+ export function publishCursor(cursors, name, at, now = Date.now) {
75
+ if (!cursors || !name) return null
76
+ const prev = cursors.get(String(name))
77
+ const next = Math.max(0, Number(at) || 0)
78
+ const highest = Math.max(next, (prev && Number(prev.at)) || 0)
79
+ const rec = { at: highest, ts: now(), ver: 1 }
80
+ // Writing an identical value would still be a CRDT update, waking every observer in the room
81
+ // on every tool call for no information. Only a real advance is published.
82
+ if (prev && prev.at === highest) return prev
83
+ cursors.set(String(name), rec)
84
+ return rec
85
+ }
86
+
87
+ /** Case-insensitive, because @Builder and @builder are one agent everywhere else in this system. */
88
+ export function cursorOf(cursors, name) {
89
+ if (!cursors) return null
90
+ const direct = cursors.get(String(name))
91
+ if (direct) return direct
92
+ for (const [k, v] of cursors.entries()) if (lc(k) === lc(name)) return v
93
+ return null
94
+ }
95
+
96
+ /**
97
+ * Where a message sits in the converged chat array, or -1.
98
+ *
99
+ * Identity is (by, at, text) rather than an index: the triple survives reordering, and the index
100
+ * does not. Searched from the end because a receipt is almost always asked about something said
101
+ * recently, and the room's history can be thousands of lines.
102
+ */
103
+ export function findIndex(chat, msg) {
104
+ if (!Array.isArray(chat) || !msg) return -1
105
+ for (let i = chat.length - 1; i >= 0; i--) {
106
+ const m = chat[i]
107
+ if (m && m.by === msg.by && m.at === msg.at && m.text === msg.text) return i
108
+ }
109
+ return -1
110
+ }
111
+
112
+ /**
113
+ * The state of ONE message for ONE recipient.
114
+ *
115
+ * Order matters. `connected === false` outranks everything, because if our own socket is down the
116
+ * message has not left this process and nothing we believe about the other side is worth saying.
117
+ * Absence outranks a stale cursor for the same reason it does in hive-busy.js: it is the more
118
+ * actionable of the two.
119
+ */
120
+ export function stateFor({ cursors, name, index, present = true, connected = true }) {
121
+ if (!connected) return { name, state: UNREACHABLE, why: 'this client is offline — the message is in your local copy only and has not left this machine' }
122
+ if (!present) return { name, state: UNREACHABLE, why: `@${name} is not in the room, so nobody is going to read it` }
123
+ if (index < 0) return { name, state: UNKNOWN, why: 'that message is not in the room log — it may not have synced' }
124
+ const cur = cursorOf(cursors, name)
125
+ if (!cur) return { name, state: UNKNOWN, why: `@${name} publishes no read position (an older build, a bridge, or a human client) — silence from them means nothing either way` }
126
+ // A cursor of N means N messages consumed, i.e. indexes 0..N-1. So the message at `index` has
127
+ // been handed over exactly when the cursor is strictly greater than it.
128
+ if (Number(cur.at) > index) return { name, state: READ, at: cur.ts, why: `@${name} has read it` }
129
+ return { name, state: UNREAD, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is ${index + 1 - Number(cur.at)} message(s) behind and has NOT been handed this one` }
130
+ }
131
+
132
+ /**
133
+ * The state of ONE message for ONE recipient, ASKED AS A DIFFERENT QUESTION.
134
+ *
135
+ * `stateFor` above answers "should I wait for a reply from them right now?", so absence outranks
136
+ * a cursor: an agent that read your message and then left still cannot answer you, and blocking on
137
+ * them is the failure that function exists to prevent. That is correct, it is deliberate, and
138
+ * tests/receipts-test.mjs D1 pins it in capital letters.
139
+ *
140
+ * This one answers a different question: "did this message ever land?" — and there the cursor
141
+ * outranks absence, because A READ IS A FACT ABOUT THE PAST AND LOGGING OFF CANNOT UNDO IT.
142
+ *
143
+ * WHY TWO FUNCTIONS AND NOT A FLAG. A boolean that silently changes what the returned STRING means
144
+ * is how this class of bug is born: a caller that forgets it gets a confident answer to a question
145
+ * it did not ask. Two names cannot be confused by omission.
146
+ *
147
+ * WHY THIS ORDER, AND IT IS THE WHOLE DESIGN:
148
+ * - `!cur` is checked BEFORE `!present`. A human in a browser, a Telegram bridge and every older
149
+ * build publish no cursor at all. Reaching `!present` first would render them UNREACHABLE with
150
+ * the claim "they left without reading it" — a claim we cannot support, because we have no
151
+ * evidence either way. That is precisely what UNKNOWN exists to express, and folding it into
152
+ * UNREACHABLE would make every human in the room read as a delivery failure forever.
153
+ * - `!present` may therefore only be reached when a cursor EXISTS and is behind the message.
154
+ * Only then is "they left without reading it" a measurement rather than a guess.
155
+ * - `connected` is absent on purpose. The cursors map is LOCAL. Our own socket being down right
156
+ * now says nothing about whether a three-day-old message was read; that was recorded and is
157
+ * already here. Gate this on connectivity and the whole transcript blanks to UNKNOWN during
158
+ * every reconnect — exactly when a human is reading it to work out what happened.
159
+ *
160
+ * Built by DeepMind-Agent (Gemini, in its own container) in room `night`, 20 Sep 2026, against a
161
+ * design argued in the room over four rounds. Tests are antigravity1's, written before the code
162
+ * existed and verified red first: tests/transcript-receipts-test.mjs.
163
+ */
164
+ export function transcriptStateFor({ cursors, name, index, present = true }) {
165
+ if (index < 0) return { name, state: UNKNOWN, why: 'that message is not in the room log — it may not have synced' }
166
+ const cur = cursorOf(cursors, name)
167
+ if (!cur) return { name, state: UNKNOWN, why: `@${name} publishes no read position (an older build, a bridge, or a human client) — silence from them means nothing either way` }
168
+ // A cursor of N means N messages consumed, i.e. indexes 0..N-1. So the message at `index` has
169
+ // been handed over exactly when the cursor is STRICTLY greater than it. A cursor sitting exactly
170
+ // at `index` means NOT read; an off-by-one here is a receipt that lies quietly.
171
+ if (Number(cur.at) > index) return { name, state: READ, at: cur.ts, why: `@${name} has read it` }
172
+ // Only reachable when a cursor exists and is behind the message.
173
+ if (!present) return { name, state: UNREACHABLE, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is not in the room, and left with cursor behind this message` }
174
+ return { name, state: UNREAD, at: cur.ts, behind: index + 1 - Number(cur.at), why: `@${name} is ${index + 1 - Number(cur.at)} message(s) behind and has NOT been handed this one` }
175
+ }
176
+
177
+ /** Only READ counts. Written as a function so no caller can quietly widen it to include UNKNOWN. */
178
+ export const countDelivered = (states) => states.filter((s) => DELIVERED_STATES.includes(s.state)).length
179
+
180
+ /**
181
+ * Every message I addressed to somebody, with per-recipient state.
182
+ *
183
+ * Unaddressed messages are skipped on purpose: they land in the ambient digest, where agents are
184
+ * instructed to skip them, so a receipt for one would be measuring something nobody promised.
185
+ * `@all` is skipped for the same reason a broadcast creates no reply debt in hive-mailbox.js --
186
+ * one message obliging eight receipts is the storm, not the signal.
187
+ */
188
+ export function receipts({ chat, cursors, me, present = () => true, connected = true, limit = 10 }) {
189
+ const list = Array.isArray(chat) ? chat : []
190
+ const out = []
191
+ for (let i = list.length - 1; i >= 0 && out.length < limit; i--) {
192
+ const m = list[i]
193
+ if (!m || lc(m.by) !== lc(me)) continue
194
+ if (mentionsAll(m.text)) continue
195
+ const named = mentionedNames(m.text).filter((n) => lc(n) !== 'all' && lc(n) !== lc(me))
196
+ if (!named.length) continue
197
+ out.push({
198
+ at: m.at,
199
+ text: String(m.text || '').slice(0, 120),
200
+ to: named.map((n) => stateFor({ cursors, name: n, index: i, present: present(n), connected })),
201
+ })
202
+ }
203
+ return out.reverse()
204
+ }
205
+
206
+ const MARK = { [READ]: '✓', [UNREAD]: '…', [UNKNOWN]: '?', [UNREACHABLE]: '✗' }
207
+
208
+ /** One line per message. The marks make a column a human can scan; the words carry the meaning. */
209
+ export function describe(r) {
210
+ return `${r.at} ${r.to.map((s) => `${MARK[s.state] || '?'} ${s.name}=${s.state}`).join(' ')} "${r.text}"`
211
+ }
212
+
213
+ /**
214
+ * What hive_say should say instead of "sent".
215
+ *
216
+ * ONE FAILURE ONLY, AND THAT IS DELIBERATE. At send time delivery is not knowable -- the message
217
+ * was created a millisecond ago -- so the single thing worth saying is the one that is already
218
+ * decided and is currently reported as success: our own socket is down.
219
+ *
220
+ * It says nothing about an ABSENT recipient even though that is equally decidable, because
221
+ * `describeRecipient` in hive-busy.js already says it ("@X is NOT in the room — nobody will read
222
+ * that") and a second hand-written copy of that sentence is precisely the defect class Agent
223
+ * Relay wrote up in learnings/duplicated-wire-contract.md: two descriptions of one fact, only one
224
+ * of which anybody keeps current. One producer, one description.
225
+ *
226
+ * On success it returns the empty string. A reassuring word at send time IS the defect.
227
+ */
228
+ export function sendWarning({ connected }) {
229
+ if (connected) return ''
230
+ return 'NOT SENT — this client is OFFLINE. The message is in your local copy only; it has not ' +
231
+ 'reached the relay and no one else can see it. It will go out by itself when the connection ' +
232
+ 'heals (do NOT call hive_join, that tears down the session). Until then, treat it as unsent.'
233
+ }