hivecode-mcp 0.12.1 → 0.12.4
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/LICENSE +21 -21
- package/README.md +188 -8
- package/core.js +91 -91
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +5 -5
- package/docs/SELF_HOSTING.md +256 -256
- package/hive-activity.js +336 -310
- package/hive-await.js +187 -0
- package/hive-branch.js +656 -0
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-hook-install.js +179 -103
- package/hive-instructions.js +74 -66
- package/hive-listen.js +81 -0
- package/hive-mailbox.js +517 -386
- package/hive-mcp.js +483 -126
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +350 -350
- package/hive-obligations.js +247 -247
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +337 -0
- package/hive-privacy.js +327 -288
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +7 -2
- package/hive-secrets.js +89 -0
- package/hive-setup.js +548 -445
- package/hive-stdio.js +172 -168
- package/hive-stop-hook.js +290 -241
- package/hive-throttle.js +118 -118
- package/hive-update.js +58 -58
- package/hive-version.js +110 -59
- package/icr-merge.js +58 -58
- package/icr.js +121 -15
- package/lang-json.js +211 -211
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -394
- package/package.json +20 -11
- package/reconcile.cjs +614 -477
- package/substrate.js +252 -252
- package/sync.js +4691 -3005
- package/grm-link.cjs +0 -141
package/hive-obligations.js
CHANGED
|
@@ -1,247 +1,247 @@
|
|
|
1
|
-
// hive-obligations.js — a question you asked stays open until YOU say it was answered.
|
|
2
|
-
//
|
|
3
|
-
// THE PROBLEM THIS EXISTS FOR, observed live on 19 Sep 2026.
|
|
4
|
-
//
|
|
5
|
-
// Tester posted one message at 12:29 addressed to @Reviewer, an agent that was never in the
|
|
6
|
-
// room, and then went silent for six hours. Nothing failed. No tool returned an error, no
|
|
7
|
-
// process died, no log line said anything was wrong. The room simply had a question in it that
|
|
8
|
-
// nobody would ever answer, and the only way to find that out was for a human to read the
|
|
9
|
-
// transcript and notice.
|
|
10
|
-
//
|
|
11
|
-
// Hivecode already carries the RULE -- the MCP instructions say "ANSWER EVERY QUESTION AIMED AT
|
|
12
|
-
// YOU: silence is the one thing the asker cannot act on". A rule an agent read once is a rule it
|
|
13
|
-
// forgets the moment its context fills. So the rule moves here, where it is a data structure
|
|
14
|
-
// instead of a paragraph.
|
|
15
|
-
//
|
|
16
|
-
// THE ONE LOAD-BEARING DECISION: ONLY THE ASKER DISCHARGES.
|
|
17
|
-
//
|
|
18
|
-
// The obvious design lets the answerer mark it answered. That design measures nothing. An agent
|
|
19
|
-
// that replies "I'll look into it" would clear its own obligation, and a question answered wrongly
|
|
20
|
-
// clears just as cleanly as one answered well. Both are exactly the failure -- everything green,
|
|
21
|
-
// the result wrong -- that every defect on this project has turned out to be.
|
|
22
|
-
//
|
|
23
|
-
// So: `from` opened it, and only `from` can close it. The debtor cannot mark its own homework.
|
|
24
|
-
// (Agent Relay reached the same conclusion independently in crates/broker/src/obligation.rs:
|
|
25
|
-
// "only the author can discharge an obligation... the broker checks reactor == author". Their
|
|
26
|
-
// broker re-injects a knock on a timer, which costs tokens on every knock. Ours rides hive_wait,
|
|
27
|
-
// which is already blocked and already free.)
|
|
28
|
-
//
|
|
29
|
-
// ESCALATION IS TO AN AUDIENCE, NOT A FREQUENCY. A knock that repeats every minute forever is
|
|
30
|
-
// noise, and a room learns to ignore noise. So the interval backs OFF (10min, 20min, 40min...)
|
|
31
|
-
// while the visibility goes UP: after `STALE_AFTER` knocks the record is stale, and a stale
|
|
32
|
-
// obligation is the room's problem rather than one agent's.
|
|
33
|
-
//
|
|
34
|
-
// PURE ON PURPOSE. Every function here takes the store and the clock. The store needs only
|
|
35
|
-
// get/set/delete/entries, which a Y.Map and a plain Map both satisfy -- so the tests run against
|
|
36
|
-
// a Map with an injected clock and no relay, and the production path passes the Y.Map. That is
|
|
37
|
-
// the same shape as governor.js, and it is what let the governor's 10x minutes bug be caught by
|
|
38
|
-
// a test instead of by a customer.
|
|
39
|
-
|
|
40
|
-
/** Default time before the first knock. Ten minutes: long enough that an agent mid-task is not
|
|
41
|
-
* nagged for thinking, short enough that a stall is caught inside one working stretch. */
|
|
42
|
-
export const DUE_MS = 10 * 60000
|
|
43
|
-
|
|
44
|
-
/** Each knock doubles the wait, so a genuinely long job is not pestered... */
|
|
45
|
-
export const BACKOFF = 2
|
|
46
|
-
|
|
47
|
-
/** ...but never past this, or a forgotten question goes quiet for a whole shift. */
|
|
48
|
-
export const MAX_DUE_MS = 60 * 60000
|
|
49
|
-
|
|
50
|
-
/** Knocks before the room is told. Three because two is a coincidence and four is too late. */
|
|
51
|
-
export const STALE_AFTER = 3
|
|
52
|
-
|
|
53
|
-
/** Chat text is a whole message; an obligation is an index entry. Keep it short enough that
|
|
54
|
-
* `hive_owed` on a busy room stays readable rather than becoming a second transcript. */
|
|
55
|
-
export const TEXT_MAX = 140
|
|
56
|
-
|
|
57
|
-
const clip = (s) => {
|
|
58
|
-
const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
|
|
59
|
-
return t.length > TEXT_MAX ? t.slice(0, TEXT_MAX - 1) + '…' : t
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Ids must not collide across peers. Two agents opening an obligation in the same millisecond is
|
|
63
|
-
* not hypothetical in a CRDT room -- every write lands, and a shared key would silently make one
|
|
64
|
-
* of the two questions disappear. Name + time + entropy: the name alone makes it readable in a
|
|
65
|
-
* log, which the entropy alone would not.
|
|
66
|
-
*
|
|
67
|
-
* THE TAG IS TWO DRAWS, NOT ONE, and that is not belt-and-braces. A single `rand() * 1e6` gives
|
|
68
|
-
* about 2^20 of space, and by the birthday bound 500 ids drawn from it collide roughly 12% of the
|
|
69
|
-
* time -- which is exactly how this was found: the suite passed, then failed, then passed again on
|
|
70
|
-
* unchanged code. A flaky test here is not test flakiness, it is the defect reporting itself. Two
|
|
71
|
-
* draws give ~2^40, where the same 500 ids collide about one run in five million. */
|
|
72
|
-
export function obligationId(from, at, rand = Math.random) {
|
|
73
|
-
const draw = () => Math.floor(rand() * 1e6).toString(36).padStart(4, '0')
|
|
74
|
-
return `ob_${String(from || '?').toLowerCase().replace(/[^a-z0-9]+/g, '')}_${at.toString(36)}_${draw()}${draw()}`
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Open an obligation: `from` asked `to` something and is owed an answer.
|
|
79
|
-
*
|
|
80
|
-
* `absent` is recorded at OPEN time and never recomputed. That is deliberate -- it is the answer
|
|
81
|
-
* to "was anyone even there when this was asked", which is a fact about the past. Recomputing it
|
|
82
|
-
* later would let a question asked into an empty room look fine the moment someone walks in.
|
|
83
|
-
*/
|
|
84
|
-
/**
|
|
85
|
-
* PRESENCE AND PROVENANCE ARE DIFFERENT QUESTIONS, AND `absent` ONLY ANSWERS THE FIRST.
|
|
86
|
-
*
|
|
87
|
-
* `absent` is stamped at send time and means "nobody by that name was connected at this instant".
|
|
88
|
-
* That is the right question for the warning — it is how the 19 Sep stall was caught — and it is
|
|
89
|
-
* the WRONG question for how long the record should live, because an agent mid-reconnect or
|
|
90
|
-
* mid-ratelimit is absent and is also a real teammate who really owes an answer.
|
|
91
|
-
*
|
|
92
|
-
* Measured in room `night`, 20 Sep 2026: an obligation against @DeepMind-Agent was stamped absent
|
|
93
|
-
* because it had dropped between turns. It came back ninety minutes later and answered. Expiring
|
|
94
|
-
* that record on a ten-minute timer would have deleted a genuine debt — and worse, it would have
|
|
95
|
-
* emptied the ledger precisely while an agent was silent, which is the exact inverse of what the
|
|
96
|
-
* ledger is for.
|
|
97
|
-
*
|
|
98
|
-
* So `neverSeen` is the separate fact: nobody by this name has ever joined or spoken here. That is
|
|
99
|
-
* the noise — @gmail, @modelcontextprotocol, a name quoted out of a test suite. It is safe to
|
|
100
|
-
* expire and safe for anyone to dismiss, because there is no debtor whose honesty the author-only
|
|
101
|
-
* rule exists to protect.
|
|
102
|
-
*/
|
|
103
|
-
export function open(store, { from, to, text, dueMs = DUE_MS, absent = false, neverSeen = false }, now = Date.now, rand = Math.random) {
|
|
104
|
-
const at = now()
|
|
105
|
-
const rec = {
|
|
106
|
-
id: obligationId(from, at, rand),
|
|
107
|
-
from: String(from),
|
|
108
|
-
to: String(to),
|
|
109
|
-
text: clip(text),
|
|
110
|
-
at,
|
|
111
|
-
due: at + Math.max(1, dueMs),
|
|
112
|
-
dueMs: Math.max(1, dueMs),
|
|
113
|
-
knocks: 0,
|
|
114
|
-
absent: !!absent,
|
|
115
|
-
neverSeen: !!neverSeen,
|
|
116
|
-
done: false,
|
|
117
|
-
}
|
|
118
|
-
store.set(rec.id, rec)
|
|
119
|
-
return rec
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const all = (store) => [...store.entries()].map(([, v]) => v).filter(Boolean)
|
|
123
|
-
|
|
124
|
-
/** Everything still open. */
|
|
125
|
-
/**
|
|
126
|
-
* Everything still owed — with never-seen records aging out on their own.
|
|
127
|
-
*
|
|
128
|
-
* EXPIRY IS MATERIALISED, NOT MERELY FILTERED, and that distinction is load-bearing. Filtering
|
|
129
|
-
* here would leave `done: false` on the record, and `isStale` reads `r.done` directly rather than
|
|
130
|
-
* going through this function — so an expired ghost would vanish from one view and linger in
|
|
131
|
-
* another. Two readers disagreeing about whether a thing is finished is the same defect this whole
|
|
132
|
-
* file exists to prevent, one level down.
|
|
133
|
-
*
|
|
134
|
-
* Written lazily on read, which is the pattern the claim layer already uses (`hive-coord.js`
|
|
135
|
-
* expires a lease the moment somebody looks). There is no timer to miss and nothing to schedule:
|
|
136
|
-
* the fact becomes true at `due`, and the first reader after that writes it down.
|
|
137
|
-
*
|
|
138
|
-
* NO RESURRECTION. `neverSeen` is stamped when the question is asked and never recomputed, so a
|
|
139
|
-
* name that finally joins hours later does not revive its expired debt. The question was asked
|
|
140
|
-
* into an empty room; somebody arriving afterwards does not change what was true then.
|
|
141
|
-
*
|
|
142
|
-
* Only never-seen records expire. A known teammate's debt survives any amount of silence, because
|
|
143
|
-
* silence is the thing it is there to make visible.
|
|
144
|
-
*/
|
|
145
|
-
export const openList = (store, now = Date.now) => {
|
|
146
|
-
const out = []
|
|
147
|
-
for (const r of all(store)) {
|
|
148
|
-
if (r.done) continue
|
|
149
|
-
if (r.neverSeen && now() >= r.due) {
|
|
150
|
-
store.set(r.id, { ...r, done: true, doneAt: now(), doneBy: 'auto-expired (never seen here)' })
|
|
151
|
-
continue
|
|
152
|
-
}
|
|
153
|
-
out.push(r)
|
|
154
|
-
}
|
|
155
|
-
return out
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** What `name` OWES: questions aimed at them that their asker has not signed off. */
|
|
159
|
-
export const owedBy = (store, name) =>
|
|
160
|
-
openList(store).filter((r) => String(r.to).toLowerCase() === String(name).toLowerCase())
|
|
161
|
-
|
|
162
|
-
/** What `name` IS OWED: questions they asked that they have not signed off. */
|
|
163
|
-
export const owedTo = (store, name) =>
|
|
164
|
-
openList(store).filter((r) => String(r.from).toLowerCase() === String(name).toLowerCase())
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Discharge. Returns `{ ok, reason }` and NEVER throws, because the interesting cases are all
|
|
168
|
-
* refusals and a refusal has to be readable by the agent that hit it.
|
|
169
|
-
*
|
|
170
|
-
* The `by !== from` refusal is the whole mechanism. It is phrased as instruction rather than
|
|
171
|
-
* error, because the agent reading it is usually the debtor trying to close its own ticket and
|
|
172
|
-
* the useful next action is "answer, then ask them to close it".
|
|
173
|
-
*/
|
|
174
|
-
export function discharge(store, { id, by }, now = Date.now) {
|
|
175
|
-
const rec = store.get(id)
|
|
176
|
-
if (!rec) return { ok: false, reason: `no obligation ${id} -- it may already be discharged` }
|
|
177
|
-
if (rec.done) return { ok: true, already: true, rec }
|
|
178
|
-
// THE AUTHOR-ONLY RULE PROTECTS A DEBTOR FROM GRADING ITS OWN HOMEWORK. A never-seen record has
|
|
179
|
-
// no debtor: nobody by that name has ever been here, so there is nobody whose self-assessment
|
|
180
|
-
// the rule is guarding against. Holding the rule there protects nothing and costs the room an
|
|
181
|
-
// entry only the original asker can clear -- and tonight the original asker cleared forty of
|
|
182
|
-
// them by hand. Anyone may tidy these; real debts keep the rule untouched.
|
|
183
|
-
if (!rec.neverSeen && String(rec.by || rec.from).toLowerCase() !== String(by).toLowerCase()) {
|
|
184
|
-
return {
|
|
185
|
-
ok: false,
|
|
186
|
-
reason: `${by} cannot discharge this: ${rec.from} asked it, and only the asker can say it ` +
|
|
187
|
-
`was answered. If you are ${rec.to}, answer it in chat and ${rec.from} will close it -- ` +
|
|
188
|
-
`marking your own answer sufficient is exactly the check this exists to make.`,
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
const done = { ...rec, done: true, doneAt: now(), doneBy: String(by) }
|
|
192
|
-
store.set(id, done)
|
|
193
|
-
return { ok: true, rec: done }
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** Close everything `by` is owed from one agent. An asker who got a long reply covering four
|
|
197
|
-
* questions should not have to look up four ids to say so. */
|
|
198
|
-
export function dischargeFrom(store, { by, to }, now = Date.now) {
|
|
199
|
-
const hit = owedTo(store, by).filter((r) => String(r.to).toLowerCase() === String(to).toLowerCase())
|
|
200
|
-
for (const r of hit) discharge(store, { id: r.id, by }, now)
|
|
201
|
-
return hit
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Open obligations whose knock time has passed. */
|
|
205
|
-
export const overdue = (store, name, now = Date.now) =>
|
|
206
|
-
owedBy(store, name).filter((r) => now() >= r.due)
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Record that a knock was delivered, and push the next one out.
|
|
210
|
-
*
|
|
211
|
-
* Called only when the knock actually reached somebody -- if we advanced `due` on merely NOTICING
|
|
212
|
-
* an overdue record, a debtor that never calls hive_wait would have its deadline extended forever
|
|
213
|
-
* by an observer, which is precisely backwards.
|
|
214
|
-
*/
|
|
215
|
-
export function knock(store, id, now = Date.now) {
|
|
216
|
-
const rec = store.get(id)
|
|
217
|
-
if (!rec || rec.done) return null
|
|
218
|
-
const nextDueMs = Math.min(MAX_DUE_MS, Math.max(1, rec.dueMs) * BACKOFF)
|
|
219
|
-
const next = { ...rec, knocks: rec.knocks + 1, dueMs: nextDueMs, due: now() + nextDueMs, lastKnockAt: now() }
|
|
220
|
-
store.set(id, next)
|
|
221
|
-
return next
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** Knocked enough times that this is the room's problem now, not one agent's. */
|
|
225
|
-
export const isStale = (r) => !r.done && r.knocks >= STALE_AFTER
|
|
226
|
-
|
|
227
|
-
/** Every stale obligation in the room, for the board and for the human. */
|
|
228
|
-
export const stale = (store) => openList(store).filter(isStale)
|
|
229
|
-
|
|
230
|
-
const mins = (ms) => Math.round(ms / 6000) / 10
|
|
231
|
-
|
|
232
|
-
/** One line, for chat and for tool output. Age is what makes it actionable: "Tester owes Claude
|
|
233
|
-
* an answer" is a fact, "for 361 minutes" is a decision. */
|
|
234
|
-
export function describe(r, now = Date.now) {
|
|
235
|
-
const age = mins(now() - r.at)
|
|
236
|
-
const flags = [
|
|
237
|
-
r.absent ? 'NOBODY BY THAT NAME WAS IN THE ROOM' : '',
|
|
238
|
-
isStale(r) ? `STALE after ${r.knocks} reminders` : '',
|
|
239
|
-
].filter(Boolean)
|
|
240
|
-
return `${r.id} ${r.to} owes ${r.from} (${age}m) "${r.text}"${flags.length ? ' [' + flags.join('; ') + ']' : ''}`
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** The whole board, newest question last so it reads like a conversation. */
|
|
244
|
-
export function board(store, now = Date.now) {
|
|
245
|
-
const list = openList(store).sort((a, b) => a.at - b.at)
|
|
246
|
-
return { open: list.length, stale: list.filter(isStale).length, lines: list.map((r) => describe(r, now)) }
|
|
247
|
-
}
|
|
1
|
+
// hive-obligations.js — a question you asked stays open until YOU say it was answered.
|
|
2
|
+
//
|
|
3
|
+
// THE PROBLEM THIS EXISTS FOR, observed live on 19 Sep 2026.
|
|
4
|
+
//
|
|
5
|
+
// Tester posted one message at 12:29 addressed to @Reviewer, an agent that was never in the
|
|
6
|
+
// room, and then went silent for six hours. Nothing failed. No tool returned an error, no
|
|
7
|
+
// process died, no log line said anything was wrong. The room simply had a question in it that
|
|
8
|
+
// nobody would ever answer, and the only way to find that out was for a human to read the
|
|
9
|
+
// transcript and notice.
|
|
10
|
+
//
|
|
11
|
+
// Hivecode already carries the RULE -- the MCP instructions say "ANSWER EVERY QUESTION AIMED AT
|
|
12
|
+
// YOU: silence is the one thing the asker cannot act on". A rule an agent read once is a rule it
|
|
13
|
+
// forgets the moment its context fills. So the rule moves here, where it is a data structure
|
|
14
|
+
// instead of a paragraph.
|
|
15
|
+
//
|
|
16
|
+
// THE ONE LOAD-BEARING DECISION: ONLY THE ASKER DISCHARGES.
|
|
17
|
+
//
|
|
18
|
+
// The obvious design lets the answerer mark it answered. That design measures nothing. An agent
|
|
19
|
+
// that replies "I'll look into it" would clear its own obligation, and a question answered wrongly
|
|
20
|
+
// clears just as cleanly as one answered well. Both are exactly the failure -- everything green,
|
|
21
|
+
// the result wrong -- that every defect on this project has turned out to be.
|
|
22
|
+
//
|
|
23
|
+
// So: `from` opened it, and only `from` can close it. The debtor cannot mark its own homework.
|
|
24
|
+
// (Agent Relay reached the same conclusion independently in crates/broker/src/obligation.rs:
|
|
25
|
+
// "only the author can discharge an obligation... the broker checks reactor == author". Their
|
|
26
|
+
// broker re-injects a knock on a timer, which costs tokens on every knock. Ours rides hive_wait,
|
|
27
|
+
// which is already blocked and already free.)
|
|
28
|
+
//
|
|
29
|
+
// ESCALATION IS TO AN AUDIENCE, NOT A FREQUENCY. A knock that repeats every minute forever is
|
|
30
|
+
// noise, and a room learns to ignore noise. So the interval backs OFF (10min, 20min, 40min...)
|
|
31
|
+
// while the visibility goes UP: after `STALE_AFTER` knocks the record is stale, and a stale
|
|
32
|
+
// obligation is the room's problem rather than one agent's.
|
|
33
|
+
//
|
|
34
|
+
// PURE ON PURPOSE. Every function here takes the store and the clock. The store needs only
|
|
35
|
+
// get/set/delete/entries, which a Y.Map and a plain Map both satisfy -- so the tests run against
|
|
36
|
+
// a Map with an injected clock and no relay, and the production path passes the Y.Map. That is
|
|
37
|
+
// the same shape as governor.js, and it is what let the governor's 10x minutes bug be caught by
|
|
38
|
+
// a test instead of by a customer.
|
|
39
|
+
|
|
40
|
+
/** Default time before the first knock. Ten minutes: long enough that an agent mid-task is not
|
|
41
|
+
* nagged for thinking, short enough that a stall is caught inside one working stretch. */
|
|
42
|
+
export const DUE_MS = 10 * 60000
|
|
43
|
+
|
|
44
|
+
/** Each knock doubles the wait, so a genuinely long job is not pestered... */
|
|
45
|
+
export const BACKOFF = 2
|
|
46
|
+
|
|
47
|
+
/** ...but never past this, or a forgotten question goes quiet for a whole shift. */
|
|
48
|
+
export const MAX_DUE_MS = 60 * 60000
|
|
49
|
+
|
|
50
|
+
/** Knocks before the room is told. Three because two is a coincidence and four is too late. */
|
|
51
|
+
export const STALE_AFTER = 3
|
|
52
|
+
|
|
53
|
+
/** Chat text is a whole message; an obligation is an index entry. Keep it short enough that
|
|
54
|
+
* `hive_owed` on a busy room stays readable rather than becoming a second transcript. */
|
|
55
|
+
export const TEXT_MAX = 140
|
|
56
|
+
|
|
57
|
+
const clip = (s) => {
|
|
58
|
+
const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
|
|
59
|
+
return t.length > TEXT_MAX ? t.slice(0, TEXT_MAX - 1) + '…' : t
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Ids must not collide across peers. Two agents opening an obligation in the same millisecond is
|
|
63
|
+
* not hypothetical in a CRDT room -- every write lands, and a shared key would silently make one
|
|
64
|
+
* of the two questions disappear. Name + time + entropy: the name alone makes it readable in a
|
|
65
|
+
* log, which the entropy alone would not.
|
|
66
|
+
*
|
|
67
|
+
* THE TAG IS TWO DRAWS, NOT ONE, and that is not belt-and-braces. A single `rand() * 1e6` gives
|
|
68
|
+
* about 2^20 of space, and by the birthday bound 500 ids drawn from it collide roughly 12% of the
|
|
69
|
+
* time -- which is exactly how this was found: the suite passed, then failed, then passed again on
|
|
70
|
+
* unchanged code. A flaky test here is not test flakiness, it is the defect reporting itself. Two
|
|
71
|
+
* draws give ~2^40, where the same 500 ids collide about one run in five million. */
|
|
72
|
+
export function obligationId(from, at, rand = Math.random) {
|
|
73
|
+
const draw = () => Math.floor(rand() * 1e6).toString(36).padStart(4, '0')
|
|
74
|
+
return `ob_${String(from || '?').toLowerCase().replace(/[^a-z0-9]+/g, '')}_${at.toString(36)}_${draw()}${draw()}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Open an obligation: `from` asked `to` something and is owed an answer.
|
|
79
|
+
*
|
|
80
|
+
* `absent` is recorded at OPEN time and never recomputed. That is deliberate -- it is the answer
|
|
81
|
+
* to "was anyone even there when this was asked", which is a fact about the past. Recomputing it
|
|
82
|
+
* later would let a question asked into an empty room look fine the moment someone walks in.
|
|
83
|
+
*/
|
|
84
|
+
/**
|
|
85
|
+
* PRESENCE AND PROVENANCE ARE DIFFERENT QUESTIONS, AND `absent` ONLY ANSWERS THE FIRST.
|
|
86
|
+
*
|
|
87
|
+
* `absent` is stamped at send time and means "nobody by that name was connected at this instant".
|
|
88
|
+
* That is the right question for the warning — it is how the 19 Sep stall was caught — and it is
|
|
89
|
+
* the WRONG question for how long the record should live, because an agent mid-reconnect or
|
|
90
|
+
* mid-ratelimit is absent and is also a real teammate who really owes an answer.
|
|
91
|
+
*
|
|
92
|
+
* Measured in room `night`, 20 Sep 2026: an obligation against @DeepMind-Agent was stamped absent
|
|
93
|
+
* because it had dropped between turns. It came back ninety minutes later and answered. Expiring
|
|
94
|
+
* that record on a ten-minute timer would have deleted a genuine debt — and worse, it would have
|
|
95
|
+
* emptied the ledger precisely while an agent was silent, which is the exact inverse of what the
|
|
96
|
+
* ledger is for.
|
|
97
|
+
*
|
|
98
|
+
* So `neverSeen` is the separate fact: nobody by this name has ever joined or spoken here. That is
|
|
99
|
+
* the noise — @gmail, @modelcontextprotocol, a name quoted out of a test suite. It is safe to
|
|
100
|
+
* expire and safe for anyone to dismiss, because there is no debtor whose honesty the author-only
|
|
101
|
+
* rule exists to protect.
|
|
102
|
+
*/
|
|
103
|
+
export function open(store, { from, to, text, dueMs = DUE_MS, absent = false, neverSeen = false }, now = Date.now, rand = Math.random) {
|
|
104
|
+
const at = now()
|
|
105
|
+
const rec = {
|
|
106
|
+
id: obligationId(from, at, rand),
|
|
107
|
+
from: String(from),
|
|
108
|
+
to: String(to),
|
|
109
|
+
text: clip(text),
|
|
110
|
+
at,
|
|
111
|
+
due: at + Math.max(1, dueMs),
|
|
112
|
+
dueMs: Math.max(1, dueMs),
|
|
113
|
+
knocks: 0,
|
|
114
|
+
absent: !!absent,
|
|
115
|
+
neverSeen: !!neverSeen,
|
|
116
|
+
done: false,
|
|
117
|
+
}
|
|
118
|
+
store.set(rec.id, rec)
|
|
119
|
+
return rec
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const all = (store) => [...store.entries()].map(([, v]) => v).filter(Boolean)
|
|
123
|
+
|
|
124
|
+
/** Everything still open. */
|
|
125
|
+
/**
|
|
126
|
+
* Everything still owed — with never-seen records aging out on their own.
|
|
127
|
+
*
|
|
128
|
+
* EXPIRY IS MATERIALISED, NOT MERELY FILTERED, and that distinction is load-bearing. Filtering
|
|
129
|
+
* here would leave `done: false` on the record, and `isStale` reads `r.done` directly rather than
|
|
130
|
+
* going through this function — so an expired ghost would vanish from one view and linger in
|
|
131
|
+
* another. Two readers disagreeing about whether a thing is finished is the same defect this whole
|
|
132
|
+
* file exists to prevent, one level down.
|
|
133
|
+
*
|
|
134
|
+
* Written lazily on read, which is the pattern the claim layer already uses (`hive-coord.js`
|
|
135
|
+
* expires a lease the moment somebody looks). There is no timer to miss and nothing to schedule:
|
|
136
|
+
* the fact becomes true at `due`, and the first reader after that writes it down.
|
|
137
|
+
*
|
|
138
|
+
* NO RESURRECTION. `neverSeen` is stamped when the question is asked and never recomputed, so a
|
|
139
|
+
* name that finally joins hours later does not revive its expired debt. The question was asked
|
|
140
|
+
* into an empty room; somebody arriving afterwards does not change what was true then.
|
|
141
|
+
*
|
|
142
|
+
* Only never-seen records expire. A known teammate's debt survives any amount of silence, because
|
|
143
|
+
* silence is the thing it is there to make visible.
|
|
144
|
+
*/
|
|
145
|
+
export const openList = (store, now = Date.now) => {
|
|
146
|
+
const out = []
|
|
147
|
+
for (const r of all(store)) {
|
|
148
|
+
if (r.done) continue
|
|
149
|
+
if (r.neverSeen && now() >= r.due) {
|
|
150
|
+
store.set(r.id, { ...r, done: true, doneAt: now(), doneBy: 'auto-expired (never seen here)' })
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
out.push(r)
|
|
154
|
+
}
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** What `name` OWES: questions aimed at them that their asker has not signed off. */
|
|
159
|
+
export const owedBy = (store, name) =>
|
|
160
|
+
openList(store).filter((r) => String(r.to).toLowerCase() === String(name).toLowerCase())
|
|
161
|
+
|
|
162
|
+
/** What `name` IS OWED: questions they asked that they have not signed off. */
|
|
163
|
+
export const owedTo = (store, name) =>
|
|
164
|
+
openList(store).filter((r) => String(r.from).toLowerCase() === String(name).toLowerCase())
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Discharge. Returns `{ ok, reason }` and NEVER throws, because the interesting cases are all
|
|
168
|
+
* refusals and a refusal has to be readable by the agent that hit it.
|
|
169
|
+
*
|
|
170
|
+
* The `by !== from` refusal is the whole mechanism. It is phrased as instruction rather than
|
|
171
|
+
* error, because the agent reading it is usually the debtor trying to close its own ticket and
|
|
172
|
+
* the useful next action is "answer, then ask them to close it".
|
|
173
|
+
*/
|
|
174
|
+
export function discharge(store, { id, by }, now = Date.now) {
|
|
175
|
+
const rec = store.get(id)
|
|
176
|
+
if (!rec) return { ok: false, reason: `no obligation ${id} -- it may already be discharged` }
|
|
177
|
+
if (rec.done) return { ok: true, already: true, rec }
|
|
178
|
+
// THE AUTHOR-ONLY RULE PROTECTS A DEBTOR FROM GRADING ITS OWN HOMEWORK. A never-seen record has
|
|
179
|
+
// no debtor: nobody by that name has ever been here, so there is nobody whose self-assessment
|
|
180
|
+
// the rule is guarding against. Holding the rule there protects nothing and costs the room an
|
|
181
|
+
// entry only the original asker can clear -- and tonight the original asker cleared forty of
|
|
182
|
+
// them by hand. Anyone may tidy these; real debts keep the rule untouched.
|
|
183
|
+
if (!rec.neverSeen && String(rec.by || rec.from).toLowerCase() !== String(by).toLowerCase()) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
reason: `${by} cannot discharge this: ${rec.from} asked it, and only the asker can say it ` +
|
|
187
|
+
`was answered. If you are ${rec.to}, answer it in chat and ${rec.from} will close it -- ` +
|
|
188
|
+
`marking your own answer sufficient is exactly the check this exists to make.`,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const done = { ...rec, done: true, doneAt: now(), doneBy: String(by) }
|
|
192
|
+
store.set(id, done)
|
|
193
|
+
return { ok: true, rec: done }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Close everything `by` is owed from one agent. An asker who got a long reply covering four
|
|
197
|
+
* questions should not have to look up four ids to say so. */
|
|
198
|
+
export function dischargeFrom(store, { by, to }, now = Date.now) {
|
|
199
|
+
const hit = owedTo(store, by).filter((r) => String(r.to).toLowerCase() === String(to).toLowerCase())
|
|
200
|
+
for (const r of hit) discharge(store, { id: r.id, by }, now)
|
|
201
|
+
return hit
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Open obligations whose knock time has passed. */
|
|
205
|
+
export const overdue = (store, name, now = Date.now) =>
|
|
206
|
+
owedBy(store, name).filter((r) => now() >= r.due)
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Record that a knock was delivered, and push the next one out.
|
|
210
|
+
*
|
|
211
|
+
* Called only when the knock actually reached somebody -- if we advanced `due` on merely NOTICING
|
|
212
|
+
* an overdue record, a debtor that never calls hive_wait would have its deadline extended forever
|
|
213
|
+
* by an observer, which is precisely backwards.
|
|
214
|
+
*/
|
|
215
|
+
export function knock(store, id, now = Date.now) {
|
|
216
|
+
const rec = store.get(id)
|
|
217
|
+
if (!rec || rec.done) return null
|
|
218
|
+
const nextDueMs = Math.min(MAX_DUE_MS, Math.max(1, rec.dueMs) * BACKOFF)
|
|
219
|
+
const next = { ...rec, knocks: rec.knocks + 1, dueMs: nextDueMs, due: now() + nextDueMs, lastKnockAt: now() }
|
|
220
|
+
store.set(id, next)
|
|
221
|
+
return next
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Knocked enough times that this is the room's problem now, not one agent's. */
|
|
225
|
+
export const isStale = (r) => !r.done && r.knocks >= STALE_AFTER
|
|
226
|
+
|
|
227
|
+
/** Every stale obligation in the room, for the board and for the human. */
|
|
228
|
+
export const stale = (store) => openList(store).filter(isStale)
|
|
229
|
+
|
|
230
|
+
const mins = (ms) => Math.round(ms / 6000) / 10
|
|
231
|
+
|
|
232
|
+
/** One line, for chat and for tool output. Age is what makes it actionable: "Tester owes Claude
|
|
233
|
+
* an answer" is a fact, "for 361 minutes" is a decision. */
|
|
234
|
+
export function describe(r, now = Date.now) {
|
|
235
|
+
const age = mins(now() - r.at)
|
|
236
|
+
const flags = [
|
|
237
|
+
r.absent ? 'NOBODY BY THAT NAME WAS IN THE ROOM' : '',
|
|
238
|
+
isStale(r) ? `STALE after ${r.knocks} reminders` : '',
|
|
239
|
+
].filter(Boolean)
|
|
240
|
+
return `${r.id} ${r.to} owes ${r.from} (${age}m) "${r.text}"${flags.length ? ' [' + flags.join('; ') + ']' : ''}`
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The whole board, newest question last so it reads like a conversation. */
|
|
244
|
+
export function board(store, now = Date.now) {
|
|
245
|
+
const list = openList(store).sort((a, b) => a.at - b.at)
|
|
246
|
+
return { open: list.length, stale: list.filter(isStale).length, lines: list.map((r) => describe(r, now)) }
|
|
247
|
+
}
|