hivecode-mcp 0.11.5 → 0.12.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.
@@ -0,0 +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
+ }
@@ -0,0 +1,189 @@
1
+ // hive-pr-staleness.js — do not write a file over a branch change you cannot account for.
2
+ //
3
+ // THE INCIDENT, MEASURED 19 Sep 2026. hive-pr generated a PR that looked perfect: three commits,
4
+ // one per agent, each agent as the real git author, signed ids in `Hivecode-Author:` trailers,
5
+ // files with no verified receipt refused rather than committed. A reviewer would have had every
6
+ // reason to trust it.
7
+ //
8
+ // Diffed against the branch it was cut from:
9
+ //
10
+ // hive-linear.js -77
11
+ // tests/hive-linear-test.mjs -114
12
+ // tests/hive-mcp-http-test.mjs -49
13
+ // tests/hive-governor-test.mjs -32
14
+ // sync.js -39
15
+ //
16
+ // It deleted the HIV-27 claim guard, deleted the HIV-14 truncation fix, deleted ~200 lines of
17
+ // tests, and restored the 10x dashboard bug -- the one where thirty minutes of runtime displays
18
+ // as 3.0. Filed HIV-28.
19
+ //
20
+ // THE CAUSE IS ONE LINE. hive-pr.js did `fs.copyFileSync(f.abs, dest)` and then `git add`. It
21
+ // treats the room as truth and writes it over the branch. Grepping that file for `ahead`,
22
+ // `behind`, `stale`, `diverged`, `merge-base` or `rev-list` returned nothing: it never asked
23
+ // whether the branch had moved.
24
+ //
25
+ // This is not an exotic case, it is the NORMAL one. Any commit reaching the branch by another
26
+ // route puts it ahead -- a hotfix, a second developer, CI, or a human doing exactly the
27
+ // review-then-push workflow this tool exists to serve. In the measured incident the other route
28
+ // was me, working in the git checkout instead of my room folder.
29
+ //
30
+ // WHY "AHEAD/BEHIND" IS THE WRONG QUESTION. There is no merge-base between a Yjs room and a git
31
+ // branch; they are not two git histories. `git rev-list --count` cannot answer this, and reaching
32
+ // for it would produce a check that looks rigorous and measures nothing.
33
+ //
34
+ // THE QUESTION THAT IS ANSWERABLE, per file:
35
+ //
36
+ // has the room ever SEEN what is currently on the branch?
37
+ //
38
+ // It can be answered exactly, from state that already exists. Every landed change appends a
39
+ // receipt carrying `parent` (the contentHash the author built on) and `contentHash` (the result),
40
+ // so a file's ledger IS a hash chain of every state the room has passed through. `provenanceOf()`
41
+ // already returns it -- hive-pr reads it for attribution and then ignores it for safety.
42
+ //
43
+ // If the branch's current bytes hash to something in that chain, the room descends from the
44
+ // branch and writing is continuing the line. If they do not, the branch holds a change the room
45
+ // has never seen, and writing would delete it. That is the whole check.
46
+
47
+ import { contentHash } from './substrate.js'
48
+
49
+ /** Nothing to write: the branch already holds exactly this. */
50
+ export const IDENTICAL = 'IDENTICAL'
51
+ /** The file is not on the branch at all, so there is nothing to revert. */
52
+ export const NEW = 'NEW'
53
+ /** The branch's bytes are in the room's chain: the room descends from them. Safe. */
54
+ export const DESCENDS = 'DESCENDS'
55
+ /** The branch holds bytes the room has never seen. REFUSE. */
56
+ export const DIVERGED = 'DIVERGED'
57
+ /** The room has no chain for this file, so nothing can be proved either way. REFUSE. */
58
+ export const UNPROVEN = 'UNPROVEN'
59
+
60
+ /** The only two states that may be written. A list, so widening it is a visible edit. */
61
+ export const WRITABLE = [NEW, DESCENDS]
62
+
63
+ /**
64
+ * LINE ENDINGS ARE PRESENTATION, AND ON THIS REPO THEY DIFFER BY DEFAULT.
65
+ *
66
+ * Measured here: `hive-mcp.js` and `sync.js` are CRLF in the working tree and LF in the blob,
67
+ * because `core.autocrlf` is true. So the bytes git stored and the bytes the room holds are not
68
+ * equal for most files in this project, while the file is by any useful definition the same file.
69
+ *
70
+ * A byte comparison would therefore mark nearly everything DIVERGED and refuse every run. That is
71
+ * worse than no check: a guard that fires on the HEALTHY path is the one that gets a `--force`
72
+ * alias in somebody's shell history within a week, and then it is not a guard at all.
73
+ *
74
+ * So both the equality test and the chain lookup are done modulo line endings. Three variants, not
75
+ * a normalisation, because the chain's hashes were computed by substrate.js over whatever text the
76
+ * room actually held at the time -- there is no single canonical form to normalise TO.
77
+ */
78
+ const variants = (t) => {
79
+ const lf = String(t).replace(/\r\n/g, '\n')
80
+ return [String(t), lf, lf.replace(/\n/g, '\r\n')]
81
+ }
82
+
83
+ /** Same file, ignoring how the lines end. */
84
+ export const sameText = (a, b) =>
85
+ a != null && b != null && String(a).replace(/\r\n/g, '\n') === String(b).replace(/\r\n/g, '\n')
86
+
87
+ /**
88
+ * Every content state this file has passed through in the room.
89
+ *
90
+ * Both fields go in. `parent` is the state an author BUILT ON -- which may be a state no receipt
91
+ * ever produced, because the first write's parent is the hash of the empty string and a rejoin
92
+ * can land on a state whose own receipt lives on another peer. Taking only `contentHash` would
93
+ * miss exactly those, and each miss reads as DIVERGED: a refusal on a healthy file, which is the
94
+ * failure mode that gets a safety check disabled.
95
+ */
96
+ export function chainOf(receipts) {
97
+ const seen = new Set()
98
+ for (const r of receipts || []) {
99
+ if (!r) continue
100
+ if (r.parent) seen.add(r.parent)
101
+ if (r.contentHash) seen.add(r.contentHash)
102
+ }
103
+ return seen
104
+ }
105
+
106
+ /**
107
+ * Decide whether the room's copy may be written over the branch's.
108
+ *
109
+ * `branchText` is null when the path does not exist on the branch. It must be the RAW blob --
110
+ * read with `git cat-file blob`, never `git show` through a working tree, or a checkout with
111
+ * `core.autocrlf` hands back bytes git never stored and every hash misses.
112
+ */
113
+ export function accountFor({ rel, branchText, roomText, receipts }) {
114
+ if (branchText == null) return { rel, state: NEW, why: 'not on the branch yet — nothing can be reverted' }
115
+ // Modulo line endings on purpose. A file that differs ONLY in how its lines end must not be
116
+ // rewritten: it would produce a diff of every line in the file, for no change anybody made.
117
+ if (sameText(branchText, roomText)) return { rel, state: IDENTICAL, why: 'the branch already holds this (line endings aside)' }
118
+
119
+ const chain = chainOf(receipts)
120
+ if (!chain.size) {
121
+ return {
122
+ rel, state: UNPROVEN,
123
+ why: 'the room has no provenance chain for this file, so there is no way to tell whether its copy is newer than the branch or older',
124
+ }
125
+ }
126
+
127
+ const hashes = variants(branchText).map(contentHash)
128
+ if (hashes.some((h) => chain.has(h))) {
129
+ return { rel, state: DESCENDS, why: 'the room has passed through the branch\'s current content, so its copy is a descendant' }
130
+ }
131
+ return {
132
+ rel, state: DIVERGED, branchHash: hashes[0],
133
+ why: 'the branch holds content this room has never seen — writing the room\'s copy would delete it',
134
+ }
135
+ }
136
+
137
+ /** May this decision be written? Never inline `state !== DIVERGED` — that admits UNPROVEN. */
138
+ export const writable = (d) => WRITABLE.includes(d.state)
139
+
140
+ /**
141
+ * The run-level verdict.
142
+ *
143
+ * ALL OR NOTHING, and that is the design. Committing the safe files and skipping the rest produces
144
+ * a PR that looks complete and is not -- the same false success the tool already produced once,
145
+ * one level up. A refusal a human can read beats a branch they have to audit.
146
+ */
147
+ export function verdict(decisions) {
148
+ const blocked = decisions.filter((d) => !writable(d) && d.state !== IDENTICAL)
149
+ return {
150
+ ok: blocked.length === 0,
151
+ blocked,
152
+ writable: decisions.filter(writable),
153
+ identical: decisions.filter((d) => d.state === IDENTICAL),
154
+ }
155
+ }
156
+
157
+ /**
158
+ * What the operator reads. `lastTouch` is supplied by the caller (hive-pr runs the git command)
159
+ * so this module stays pure and testable without a repository.
160
+ *
161
+ * It names the commit and the person, because "diverged" alone sends someone to `git log` to work
162
+ * out whether this is their own forgotten hotfix or somebody else's live work -- and that lookup
163
+ * is the moment a tired operator reaches for --force.
164
+ */
165
+ export function explain(blocked, lastTouch = () => '') {
166
+ // Q506, the next free code in hive-pr's own Q500-Q505 block. Q443 would have read naturally
167
+ // here -- it is the other "you are being handed an incomplete picture" refusal -- and it is
168
+ // already the Linear truncation code. Two meanings behind one code is the duplicated-wire
169
+ // -contract defect wearing a three-digit hat.
170
+ const lines = ['Q506 REFUSING TO WRITE — the branch has moved under these files:', '']
171
+ for (const d of blocked) {
172
+ const touch = lastTouch(d.rel)
173
+ lines.push(` ${d.rel}`)
174
+ lines.push(` ${d.why}`)
175
+ if (touch) lines.push(` last changed on the branch by: ${touch}`)
176
+ }
177
+ lines.push('')
178
+ lines.push('Nothing was written and no branch was created. Writing the room\'s copy over these')
179
+ lines.push('would silently revert whatever reached the branch another way — a hotfix, a second')
180
+ lines.push('developer, CI, or your own work done outside the room. That has happened once on this')
181
+ lines.push('project and the resulting PR looked perfect.')
182
+ lines.push('')
183
+ lines.push('Fix it one of two ways:')
184
+ lines.push(' - bring the branch\'s version INTO the room (edit it there, or hive_edit it), so the')
185
+ lines.push(' room\'s copy descends from what is on the branch. Then run this again.')
186
+ lines.push(' - --force "<reason>" if you have decided the room\'s copy should win. The reason is')
187
+ lines.push(' recorded in the PR body, because somebody will ask.')
188
+ return lines.join('\n')
189
+ }