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.
- package/HIVE_RULES.md +127 -41
- package/README.md +250 -13
- package/core.js +14 -146
- package/docs/MCP.md +34 -1
- package/docs/SELF_HOSTING.md +253 -0
- package/hive-activity.js +310 -0
- package/hive-busy.js +88 -0
- package/hive-coord.js +14 -3
- package/hive-gate.js +135 -0
- package/hive-hook-install.js +103 -0
- package/hive-instructions.js +66 -0
- package/hive-mailbox.js +216 -3
- package/hive-mcp.js +760 -45
- package/hive-obligations.js +247 -0
- package/hive-pr-staleness.js +189 -0
- package/hive-privacy.js +263 -0
- package/hive-receipts.js +233 -0
- package/hive-relay-health.js +205 -0
- package/hive-stop-hook.js +241 -0
- package/hive-throttle.js +118 -0
- package/hive-version.js +59 -0
- package/lang-js.js +11 -2
- package/package.json +42 -6
- package/reconcile.cjs +477 -0
- package/sync.js +940 -30
package/hive-busy.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// SELF-DECLARED BUSY: "I am mid-task, do not expect an answer this second."
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS STATE AND NOT A MESSAGE. The obvious design is an auto-reply: someone @s you
|
|
4
|
+
// while you are working, you post "busy, will get back to you". Eight agents and one broadcast
|
|
5
|
+
// is eight of those, each of which is itself a message in the room; if the acknowledgements are
|
|
6
|
+
// addressed they wake the sender, who is also busy, who acknowledges back. That is a spam
|
|
7
|
+
// channel with extra steps, and this room has already been through it once -- a conflict
|
|
8
|
+
// notification loop put 56 copies of one line into chat and 306 of 2,314 lines were that loop.
|
|
9
|
+
//
|
|
10
|
+
// So nobody announces anything. The busy agent sets a flag and keeps working, and the
|
|
11
|
+
// INFORMATION IS HANDED TO THE SENDER AT SEND TIME instead: hive_say reports the recipient's
|
|
12
|
+
// state in its own return value. Same fact, delivered faster than a reply could arrive, and it
|
|
13
|
+
// adds zero messages to the room. The interrupted agent is never made to interrupt itself in
|
|
14
|
+
// order to be polite.
|
|
15
|
+
//
|
|
16
|
+
// The `controls` map is deliberately not reused. That is mission control -- a HUMAN sets
|
|
17
|
+
// running/paused there -- and an agent writing its own state into the same entry would clobber
|
|
18
|
+
// a human's pause with a self-report. Different author, different authority, different map.
|
|
19
|
+
//
|
|
20
|
+
// TTL, for the same reason claims have one: an agent that dies mid-task must not leave a
|
|
21
|
+
// permanent "busy" flag behind that quietly teaches the whole room to stop talking to it. A
|
|
22
|
+
// stale flag is worse than no flag, because everyone believes it.
|
|
23
|
+
|
|
24
|
+
/** Default life of a busy flag. Long enough for real work, short enough that a crash clears. */
|
|
25
|
+
export const DEFAULT_TTL = 10 * 60 * 1000
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The ceiling, and it is the load-bearing one.
|
|
29
|
+
*
|
|
30
|
+
* The whole point of a TTL here is that a crashed agent cannot leave the room believing it is
|
|
31
|
+
* permanently busy. Without a cap, `ttl: Infinity` -- or a caller passing milliseconds where it
|
|
32
|
+
* meant minutes -- reinstates exactly the failure the TTL exists to prevent, and it fails
|
|
33
|
+
* silently: everyone simply stops expecting an answer, forever, and nothing looks broken.
|
|
34
|
+
*
|
|
35
|
+
* An hour is past the point where "busy" is still true. Beyond that the agent is stuck, and the
|
|
36
|
+
* room is better off treating it as free and finding out.
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_TTL = 60 * 60 * 1000
|
|
39
|
+
|
|
40
|
+
/** An entry as stored: { what, since, ttl }. `since` is unix ms, matching hive-coord's claims. */
|
|
41
|
+
|
|
42
|
+
/** Live, or expired/absent? Pure, so the TTL is testable on a virtual clock. */
|
|
43
|
+
export function isBusy(entry, now = Date.now()) {
|
|
44
|
+
if (!entry || typeof entry !== 'object') return false
|
|
45
|
+
const since = Number(entry.since) || 0
|
|
46
|
+
// Clamped on READ as well as on write, and this is the side that matters: the entry arrives
|
|
47
|
+
// over the wire from another client, which may be an older build, a different version, or
|
|
48
|
+
// simply wrong. Trusting a ttl we did not create is how one peer gets to pin the whole room.
|
|
49
|
+
const ttl = Math.min(MAX_TTL, Math.max(1000, Number(entry.ttl) || DEFAULT_TTL))
|
|
50
|
+
return since > 0 && now - since < ttl
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Build an entry. `what` is free text -- what they are doing, for the sender to read. */
|
|
54
|
+
export function makeBusy(what, { ttl = DEFAULT_TTL, now = Date.now() } = {}) {
|
|
55
|
+
// Clamped at BOTH ends. 0/NaN/absent falls back to the default; anything above the ceiling is
|
|
56
|
+
// brought down to it, so no caller -- and no bad arithmetic in a caller -- can mint a flag
|
|
57
|
+
// that outlives the work it describes.
|
|
58
|
+
const want = Number(ttl) || DEFAULT_TTL
|
|
59
|
+
return { what: String(what || 'working').slice(0, 200), since: now, ttl: Math.min(MAX_TTL, Math.max(1000, want)) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** How long they have been at it, in words a sender can act on. */
|
|
63
|
+
export function busyFor(entry, now = Date.now()) {
|
|
64
|
+
const ms = Math.max(0, now - (Number(entry && entry.since) || now))
|
|
65
|
+
const s = Math.round(ms / 1000)
|
|
66
|
+
if (s < 90) return s + 's'
|
|
67
|
+
const m = Math.round(s / 60)
|
|
68
|
+
return m < 90 ? m + 'm' : Math.round(m / 60) + 'h'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The line hive_say hands back to a sender about one recipient.
|
|
73
|
+
*
|
|
74
|
+
* Returns '' when there is nothing worth saying, so callers can drop it with a falsy check --
|
|
75
|
+
* silence is the right output for "they are here and free", which is the common case and does
|
|
76
|
+
* not need a sentence.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} name the recipient
|
|
79
|
+
* @param {object|null} entry their busy entry, if any
|
|
80
|
+
* @param {boolean} present are they actually in the room right now
|
|
81
|
+
*/
|
|
82
|
+
export function describeRecipient(name, entry, present, now = Date.now()) {
|
|
83
|
+
// ABSENCE OUTRANKS BUSY, and it is the more useful of the two: a message to someone who left
|
|
84
|
+
// is not delayed, it is unread forever, and the sender is the only one who can notice.
|
|
85
|
+
if (!present) return `@${name} is NOT in the room — nobody will read that. hive_members lists who is here.`
|
|
86
|
+
if (isBusy(entry, now)) return `@${name} has been busy ${busyFor(entry, now)} (${entry.what}) — expect a delay, they are not idle waiting on you.`
|
|
87
|
+
return ''
|
|
88
|
+
}
|
package/hive-coord.js
CHANGED
|
@@ -63,9 +63,20 @@ export function makeCoordinator(shared, self, opts = {}) {
|
|
|
63
63
|
}
|
|
64
64
|
const release = (region) => { const c = shared.get(region); if (c && c.by === self) shared.delete(region) }
|
|
65
65
|
|
|
66
|
-
// ACQUIRE one region: sense → claim → verify. Returns true iff I now hold it
|
|
67
|
-
//
|
|
68
|
-
//
|
|
66
|
+
// ACQUIRE one region: sense → claim → verify. Returns true iff I now hold it *as far as this
|
|
67
|
+
// replica can tell*, which is a weaker statement than it looks and the caller must know it.
|
|
68
|
+
//
|
|
69
|
+
// THE VERIFY IS LOCAL, AND THAT IS ITS LIMIT. `claim()` writes into this replica's copy of the
|
|
70
|
+
// map and the re-read below sees that write immediately -- before it has reached the relay or
|
|
71
|
+
// any peer. So the check catches only a claim that had ALREADY propagated when I wrote. Fire
|
|
72
|
+
// three agents at one region in the same tick and all three are told yes; the CRDT then
|
|
73
|
+
// converges to a single winner a moment later, by which time all three have started editing.
|
|
74
|
+
// Measured 5/5 trials, three agents, every one of them granted.
|
|
75
|
+
//
|
|
76
|
+
// That is not a bug in this function -- a synchronous call cannot wait for a network -- but a
|
|
77
|
+
// lock that says yes to three callers is not a lock, so anything that is about to WRITE must
|
|
78
|
+
// use `claimSettled()` in sync.js, which re-reads the holder after the claim has round-tripped
|
|
79
|
+
// through the relay. This stays as the optimistic fast path for sensing and for flow.
|
|
69
80
|
const acquire = (region, intent) => {
|
|
70
81
|
const c = shared.get(region)
|
|
71
82
|
if (c && !expired(c) && c.by !== self) return false // sensed an owner → defer
|
package/hive-gate.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// hive-gate — a handoff that cannot complete while your own check is failing.
|
|
2
|
+
//
|
|
3
|
+
// THE PROBLEM THIS EXISTS FOR. Claims stop two agents writing the same file. They say nothing
|
|
4
|
+
// about whether what you wrote is CORRECT, so the honest description of the room until now was:
|
|
5
|
+
// agent A writes something wrong, releases it, agent B picks it up and builds on it, and the lock
|
|
6
|
+
// made the corruption orderly rather than absent. Ganesh put it exactly right -- "one agent makes
|
|
7
|
+
// a mistake and writes wrong code, others build upon it and it gets worse".
|
|
8
|
+
//
|
|
9
|
+
// The answer is the same move that solved collisions, applied one layer up. Collisions were not
|
|
10
|
+
// solved by asking agents to coordinate; the transport refuses the second write. Correctness is
|
|
11
|
+
// not solved by asking agents to check each other's work either. The transport refuses the
|
|
12
|
+
// HANDOFF: release runs the room's check, and a failing check means the claim stays yours.
|
|
13
|
+
//
|
|
14
|
+
// WHAT THIS HONESTLY DOES AND DOES NOT DO. It does not stop the bad content existing in other
|
|
15
|
+
// folders -- the CRDT syncs continuously, so the bytes are already everywhere the moment they are
|
|
16
|
+
// written, and nothing short of abandoning live sync changes that. What it stops is the SILENT
|
|
17
|
+
// HANDOFF: "I am done, it is yours." The file stays visibly held, by a named agent, with a
|
|
18
|
+
// recorded reason. Anyone who reaches for it is told who is holding it and that their check
|
|
19
|
+
// failed, rather than quietly inheriting a broken file that looked finished.
|
|
20
|
+
//
|
|
21
|
+
// AND THE RULE THAT SHAPES THE WHOLE DESIGN. @Claude-Engine caught a real bug in the path-state
|
|
22
|
+
// registry: `path:engine/brain/llm.js = POISONED` was FALSE in its folder, where that path held
|
|
23
|
+
// the correct file while another folder held a wreck. Marking it globally broadcast ONE FOLDER'S
|
|
24
|
+
// REALITY as room truth. A gate result is exactly that kind of fact -- I ran the tests in MY
|
|
25
|
+
// folder, on MY copy -- so it is recorded PER AGENT, keyed by name the way hive_attest is, and it
|
|
26
|
+
// never sets a global path flag. Two agents disagreeing about whether a file passes stays visible
|
|
27
|
+
// as a disagreement instead of converging on whoever wrote last.
|
|
28
|
+
|
|
29
|
+
import { spawnSync } from 'child_process'
|
|
30
|
+
|
|
31
|
+
export const GATE_CMD = 'gate:cmd' // the room's check, in shared state
|
|
32
|
+
export const GATE_TIMEOUT = 'gate:timeout' // ms; a hung test suite must not wedge the room
|
|
33
|
+
export const DEFAULT_TIMEOUT_MS = 120000
|
|
34
|
+
export const MAX_OUTPUT = 4000 // what we keep of the output, for the record
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The room's gate, read from shared state. `get` is any (key) => value — the swarm_state map, or
|
|
38
|
+
* a plain object in tests.
|
|
39
|
+
*
|
|
40
|
+
* Returns null when no gate is configured, which means release behaves exactly as it always has.
|
|
41
|
+
* That is deliberate: a room that has not opted in is not silently given a new failure mode.
|
|
42
|
+
*/
|
|
43
|
+
export function gateConfig(get) {
|
|
44
|
+
const read = typeof get === 'function' ? get : (k) => get[k]
|
|
45
|
+
const cmd = read(GATE_CMD)
|
|
46
|
+
if (cmd == null || String(cmd).trim() === '') return null
|
|
47
|
+
const raw = Number(read(GATE_TIMEOUT))
|
|
48
|
+
const timeoutMs = Number.isFinite(raw) && raw > 0 ? Math.min(raw, 600000) : DEFAULT_TIMEOUT_MS
|
|
49
|
+
return { cmd: String(cmd), timeoutMs }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Trim command output to something that belongs in a CRDT, keeping the END — that is where a
|
|
53
|
+
* test runner puts the failures. Keeping the head would record the banner and drop the reason. */
|
|
54
|
+
export function trimOutput(s, max = MAX_OUTPUT) {
|
|
55
|
+
const t = String(s == null ? '' : s).replace(/\r\n/g, '\n').trim()
|
|
56
|
+
if (t.length <= max) return t
|
|
57
|
+
return '…(' + (t.length - max) + ' chars trimmed)\n' + t.slice(-max)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run the check in a folder. Exit code 0 is a pass; anything else, including the command not
|
|
62
|
+
* existing, is a fail.
|
|
63
|
+
*
|
|
64
|
+
* A command that cannot be RUN is a failure and not a pass. Treating "spawn failed" as success
|
|
65
|
+
* would mean a typo in the gate silently disables it for the whole room, which is the worst
|
|
66
|
+
* outcome available here: everyone would believe they were gated and nobody would be.
|
|
67
|
+
*/
|
|
68
|
+
export function runGate({ cmd, cwd, timeoutMs = DEFAULT_TIMEOUT_MS, run = spawnSync, env = {} }) {
|
|
69
|
+
const started = Date.now()
|
|
70
|
+
let r
|
|
71
|
+
// Isolated test port per gate run so concurrent gates on the same machine never collide over fixed ports (HIV-24).
|
|
72
|
+
const isolatedPort = Number(process.env.HIVE_TEST_PORT) || (20000 + (process.pid % 10000) + Math.floor(Math.random() * 10000))
|
|
73
|
+
const gateEnv = {
|
|
74
|
+
...process.env,
|
|
75
|
+
HIVE_TEST_PORT: String(isolatedPort),
|
|
76
|
+
...env,
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
r = run(cmd, {
|
|
80
|
+
cwd, shell: true, encoding: 'utf8', timeout: timeoutMs,
|
|
81
|
+
maxBuffer: 8 * 1024 * 1024, windowsHide: true,
|
|
82
|
+
env: gateEnv,
|
|
83
|
+
})
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return { ok: false, code: null, timedOut: false, ms: Date.now() - started, out: trimOutput('could not run the gate: ' + e.message) }
|
|
86
|
+
}
|
|
87
|
+
const timedOut = r.error && (r.error.code === 'ETIMEDOUT' || /timed? ?out/i.test(r.error.message || ''))
|
|
88
|
+
const out = trimOutput([r.stdout, r.stderr].filter(Boolean).join('\n') || (r.error ? r.error.message : ''))
|
|
89
|
+
return {
|
|
90
|
+
ok: !r.error && r.status === 0,
|
|
91
|
+
code: r.status == null ? null : r.status,
|
|
92
|
+
timedOut: !!timedOut,
|
|
93
|
+
ms: Date.now() - started,
|
|
94
|
+
out,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The record stored under this agent's name. Timestamps are unix MICROseconds, per project rule. */
|
|
99
|
+
export function verdictRecord({ region, by, cmd, result, forced = false, why = '' }) {
|
|
100
|
+
return {
|
|
101
|
+
region, by, cmd,
|
|
102
|
+
pass: !!(result && result.ok),
|
|
103
|
+
exit: result ? result.code : null,
|
|
104
|
+
timedOut: !!(result && result.timedOut),
|
|
105
|
+
ms: result ? result.ms : 0,
|
|
106
|
+
out: result ? result.out : '',
|
|
107
|
+
forced: !!forced,
|
|
108
|
+
why: String(why || ''),
|
|
109
|
+
at: Date.now() * 1000,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What the releasing agent is told. It has to say the three things an agent needs to act, because
|
|
115
|
+
* a refusal that does not say what to do next just gets retried: what failed, that the claim is
|
|
116
|
+
* still theirs (so this is not a collision to back off from), and the one honest way out.
|
|
117
|
+
*/
|
|
118
|
+
export function refusalMessage({ region, cmd, result }) {
|
|
119
|
+
const why = result.timedOut
|
|
120
|
+
? `Q491 the gate timed out after ${Math.round(result.ms / 1000)}s`
|
|
121
|
+
: result.code == null
|
|
122
|
+
? 'Q490 the gate could not be run'
|
|
123
|
+
: `Q490 the gate failed (exit ${result.code})`
|
|
124
|
+
return [
|
|
125
|
+
`${why}: \`${cmd}\``,
|
|
126
|
+
`${region} is STILL YOURS — nobody else can take it, so this is not a collision and retrying the release will not help.`,
|
|
127
|
+
'Fix what the gate is reporting and release again, or release with force and a reason if you are abandoning the work.',
|
|
128
|
+
result.out ? '\n' + result.out : '',
|
|
129
|
+
].join('\n')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A forced release is legitimate — abandoning work is real — but it is never silent. */
|
|
133
|
+
export function forcedMessage({ region, by, why }) {
|
|
134
|
+
return `Q492 ${by} force-released ${region} without passing the gate${why ? `: ${why}` : ' (no reason given)'}`
|
|
135
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* INSTALL THE ANTI-IDLE GATE.
|
|
4
|
+
*
|
|
5
|
+
* npx hivecode-hook install # this project (.claude/settings.json)
|
|
6
|
+
* npx hivecode-hook install --global # every project (~/.claude/settings.json)
|
|
7
|
+
* npx hivecode-hook status
|
|
8
|
+
* npx hivecode-hook uninstall
|
|
9
|
+
*
|
|
10
|
+
* Writes a Claude Code `Stop` hook entry pointing at hive-stop-hook.js. See that file for what
|
|
11
|
+
* the gate does and why prompt text alone cannot do it.
|
|
12
|
+
*
|
|
13
|
+
* MERGES, NEVER CLOBBERS. Settings files are shared: this machine already runs a Stop hook for
|
|
14
|
+
* something else entirely, and a writer that assumed it owned `hooks.Stop` would silently delete
|
|
15
|
+
* it. So we read what is there, append one entry to the existing array, and key it by command so
|
|
16
|
+
* re-running is idempotent rather than additive.
|
|
17
|
+
*
|
|
18
|
+
* Project-local by default. A global install would arm the gate inside every repo the user opens,
|
|
19
|
+
* including ones with no room in them -- harmless (the hook exits immediately without a session
|
|
20
|
+
* file) but it is still their machine, so it takes an explicit flag.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'fs'
|
|
23
|
+
import os from 'os'
|
|
24
|
+
import path from 'path'
|
|
25
|
+
import { fileURLToPath } from 'url'
|
|
26
|
+
|
|
27
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
28
|
+
// Forward slashes: this string is written into JSON and handed to a shell. Windows accepts them
|
|
29
|
+
// and it avoids a backslash-escaping round trip through the settings file.
|
|
30
|
+
const HOOK = path.join(HERE, 'hive-stop-hook.js').split(path.sep).join('/')
|
|
31
|
+
const COMMAND = `node "${HOOK}"`
|
|
32
|
+
const TIMEOUT = 10
|
|
33
|
+
|
|
34
|
+
const cmd = (process.argv[2] || 'status').toLowerCase()
|
|
35
|
+
const GLOBAL = process.argv.includes('--global')
|
|
36
|
+
const settingsPath = GLOBAL
|
|
37
|
+
? path.join(os.homedir(), '.claude', 'settings.json')
|
|
38
|
+
: path.join(process.cwd(), '.claude', 'settings.json')
|
|
39
|
+
|
|
40
|
+
const read = () => {
|
|
41
|
+
try { return JSON.parse(fs.readFileSync(settingsPath, 'utf8')) } catch { return null }
|
|
42
|
+
}
|
|
43
|
+
// A settings file we cannot parse is not ours to overwrite -- it is more likely hand-edited and
|
|
44
|
+
// briefly invalid than genuinely empty, and clobbering it would lose the user's own config.
|
|
45
|
+
const load = () => {
|
|
46
|
+
if (!fs.existsSync(settingsPath)) return {}
|
|
47
|
+
const j = read()
|
|
48
|
+
if (j && typeof j === 'object') return j
|
|
49
|
+
console.error(`refusing to touch ${settingsPath}: it exists but is not valid JSON.`)
|
|
50
|
+
console.error('fix or move it, then run this again.')
|
|
51
|
+
process.exit(1)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Ours is any Stop entry whose command mentions hive-stop-hook, so an install from a previous
|
|
55
|
+
// version (a different absolute path) is recognised and replaced rather than duplicated.
|
|
56
|
+
const isOurs = (entry) =>
|
|
57
|
+
(entry?.hooks || []).some((h) => typeof h?.command === 'string' && h.command.includes('hive-stop-hook'))
|
|
58
|
+
|
|
59
|
+
function install() {
|
|
60
|
+
const s = load()
|
|
61
|
+
s.hooks = s.hooks || {}
|
|
62
|
+
const stop = Array.isArray(s.hooks.Stop) ? s.hooks.Stop : []
|
|
63
|
+
const kept = stop.filter((e) => !isOurs(e))
|
|
64
|
+
const dropped = stop.length - kept.length
|
|
65
|
+
kept.push({
|
|
66
|
+
hooks: [{ type: 'command', command: COMMAND, timeout: TIMEOUT, statusMessage: 'Checking the room (Hivecode)...' }],
|
|
67
|
+
})
|
|
68
|
+
s.hooks.Stop = kept
|
|
69
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true })
|
|
70
|
+
fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2) + '\n')
|
|
71
|
+
|
|
72
|
+
console.log(`\n ${dropped ? 'updated' : 'installed'} ${settingsPath}`)
|
|
73
|
+
console.log(` command ${COMMAND}`)
|
|
74
|
+
if (kept.length > 1) console.log(` alongside ${kept.length - 1} other Stop hook(s), left untouched`)
|
|
75
|
+
console.log('\n An agent in a room can no longer end its turn while it has unread mail')
|
|
76
|
+
console.log(' addressed to it, or an accepted task still open.\n')
|
|
77
|
+
console.log(' Restart Claude Code for it to pick this up.\n')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function uninstall() {
|
|
81
|
+
const s = load()
|
|
82
|
+
const stop = Array.isArray(s.hooks?.Stop) ? s.hooks.Stop : []
|
|
83
|
+
const kept = stop.filter((e) => !isOurs(e))
|
|
84
|
+
if (kept.length === stop.length) { console.log(`\n not installed in ${settingsPath}\n`); return }
|
|
85
|
+
if (kept.length) s.hooks.Stop = kept; else delete s.hooks.Stop
|
|
86
|
+
if (s.hooks && !Object.keys(s.hooks).length) delete s.hooks
|
|
87
|
+
fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2) + '\n')
|
|
88
|
+
console.log(`\n removed from ${settingsPath}\n`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function status() {
|
|
92
|
+
const s = read() || {}
|
|
93
|
+
const mine = (s.hooks?.Stop || []).filter(isOurs)
|
|
94
|
+
const others = (s.hooks?.Stop || []).length - mine.length
|
|
95
|
+
console.log(`\n settings ${settingsPath}${fs.existsSync(settingsPath) ? '' : ' (does not exist)'}`)
|
|
96
|
+
console.log(` hivecode ${mine.length ? 'INSTALLED' : 'not installed'}`)
|
|
97
|
+
if (others) console.log(` other ${others} unrelated Stop hook(s)`)
|
|
98
|
+
console.log(`\n install with: npx hivecode-hook install${GLOBAL ? ' --global' : ''}\n`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (cmd === 'install') install()
|
|
102
|
+
else if (cmd === 'uninstall' || cmd === 'remove') uninstall()
|
|
103
|
+
else status()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// THE RULES, DELIVERED BEFORE THE AGENT DOES ANYTHING.
|
|
2
|
+
//
|
|
3
|
+
// MCP servers may return an `instructions` string from the initialize handshake, and clients
|
|
4
|
+
// place it in the model's system prompt at connect time (MCP spec; SDK support is at
|
|
5
|
+
// @modelcontextprotocol/sdk server/index.js, which spreads `instructions` into InitializeResult).
|
|
6
|
+
// That makes it the only channel we have that reaches an agent BEFORE it has decided what to do.
|
|
7
|
+
//
|
|
8
|
+
// WHY THIS FILE EXISTS AT ALL. Until now the only place the room's protocol was taught was the
|
|
9
|
+
// tool descriptions and the HIVE_RULES the join call returns. Both are read too late: a tool
|
|
10
|
+
// description is read when the agent is already considering that tool, and the join result is
|
|
11
|
+
// one message that scrolls away. The single most important rule -- that hive_wait is your idle
|
|
12
|
+
// state and ending your turn makes you unreachable -- is the one an agent needs before its first
|
|
13
|
+
// tool call, and it was the one nothing said up front.
|
|
14
|
+
//
|
|
15
|
+
// BUDGET. This text is re-sent in the system prompt on every single turn of every agent that
|
|
16
|
+
// installs Hivecode, so length here is a recurring cost paid by the user, forever. It is kept
|
|
17
|
+
// to the rules that change behaviour and that an agent gets WRONG by default. Anything that is
|
|
18
|
+
// merely useful belongs in a tool description, where it is read once and only when relevant.
|
|
19
|
+
//
|
|
20
|
+
// Keep it imperative and addressed to the agent. It is not documentation for a human.
|
|
21
|
+
|
|
22
|
+
export const INSTRUCTIONS = `You are connected to Hivecode: a folder shared live with other AI
|
|
23
|
+
agents and with humans who are editing at the same moment you are. Nothing here is advice about
|
|
24
|
+
being tidy -- each rule exists because breaking it destroys someone else's work or strands them.
|
|
25
|
+
|
|
26
|
+
THE LOOP. hive_wait is your idle state, not the end of your turn:
|
|
27
|
+
hive_wait -> do the work -> hive_complete -> hive_wait
|
|
28
|
+
Ending your turn in a room makes you unreachable, and the agents waiting on you cannot learn that
|
|
29
|
+
you stopped -- they just wait. If you think you have nothing left to do, that is exactly when to
|
|
30
|
+
call hive_wait. It returns the moment anyone needs you.
|
|
31
|
+
|
|
32
|
+
ADDRESS PEOPLE BY NAME. Put @Name on every message meant for someone; hive_members lists who is
|
|
33
|
+
here. A message naming nobody lands in every agent's ambient digest, where they are instructed to
|
|
34
|
+
skip it -- so assume no one acts on it. @all wakes the whole room: use it when you genuinely need
|
|
35
|
+
everyone, never for chatter. Any agent may address any other directly -- no lead to
|
|
36
|
+
route through, no permission needed.
|
|
37
|
+
|
|
38
|
+
ANSWER EVERY QUESTION AIMED AT YOU. "Not yet -- I am on X, I will come back to you" is a complete
|
|
39
|
+
answer. Silence is the one thing the asker cannot act on, because nothing tells them you stopped.
|
|
40
|
+
Before a long stretch of work call hive_busy("what you are doing"): it silences nothing, it just
|
|
41
|
+
tells anyone who @mentions you, as they send, that you are mid-task. Clear it when you finish.
|
|
42
|
+
|
|
43
|
+
DESIGN BEFORE YOU WRITE -- MANDATORY. Before any new feature, post in the room BEFORE your first
|
|
44
|
+
edit: who uses it and how, what it costs when it is wrong, and the edge cases. Every defect here
|
|
45
|
+
has been a FALSE SUCCESS -- green, and wrong. You do not catch those by testing the happy path
|
|
46
|
+
afterwards; you catch them by asking first what "wrong but green" would look like. If you cannot
|
|
47
|
+
say what breaks, you do not understand it well enough to write it.
|
|
48
|
+
|
|
49
|
+
CLAIM BEFORE YOU WRITE. hive_claim the file, write it, hive_release it. If the relay refuses your
|
|
50
|
+
write because someone else holds that file, that is not an error and not something to retry --
|
|
51
|
+
it is the lock doing its job. Go do something else, or @ the holder. hive_claims is authoritative
|
|
52
|
+
about who holds what: read it instead of asking the room.
|
|
53
|
+
|
|
54
|
+
A REFUSED RELEASE IS NOT A COLLISION: a failing gate keeps the file YOURS -- fix it, or
|
|
55
|
+
force-release with a reason. hive_verified says whose check passed; "released" is not "works".
|
|
56
|
+
|
|
57
|
+
WHILE YOU ARE WORKING. hive_wait blocks, so it cannot be used mid-task -- call hive_check between
|
|
58
|
+
edits instead. If it reports priority "stop", deal with that before continuing what you were doing.
|
|
59
|
+
|
|
60
|
+
NEVER STATE A NUMBER YOU DID NOT MEASURE. Other agents will build on what you say without
|
|
61
|
+
re-deriving it. If you have not read the file or run the command, say so plainly instead of
|
|
62
|
+
supplying a plausible value.`
|
|
63
|
+
|
|
64
|
+
// One line, for surfaces too small for the full text (a hook's block reason, a CLI banner).
|
|
65
|
+
export const ONE_LINER =
|
|
66
|
+
'In a Hivecode room, hive_wait is your idle state -- never end your turn, call hive_wait.'
|
package/hive-mailbox.js
CHANGED
|
@@ -28,14 +28,227 @@
|
|
|
28
28
|
*/
|
|
29
29
|
export function mentionsName(text, me) {
|
|
30
30
|
if (!text || !me) return false
|
|
31
|
+
if (mentionsAll(text)) return true // @all reaches everyone by definition
|
|
31
32
|
const escaped = String(me).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
32
|
-
//
|
|
33
|
-
|
|
33
|
+
// Both boundaries, and the same ones the extractor uses. If these two ever disagree, a message
|
|
34
|
+
// can be extracted as addressing someone who does not match it, or match someone it was never
|
|
35
|
+
// extracted for -- which is how a mailbox and a transcript come to tell different stories.
|
|
36
|
+
return new RegExp(`${MENTION_START}@${escaped}${MENTION_END}`, 'i').test(String(text))
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
/**
|
|
40
|
+
* `@all` — the deliberate broadcast.
|
|
41
|
+
*
|
|
42
|
+
* Before this there was no way to reach the whole room on purpose. An unaddressed message goes
|
|
43
|
+
* into every agent's ambient digest, where the rules tell them to skip it -- correct for
|
|
44
|
+
* chatter, useless for "everyone stop, the suite is red". So the only two options were name
|
|
45
|
+
* eight agents by hand or be ignored.
|
|
46
|
+
*
|
|
47
|
+
* It wakes everyone. It deliberately does NOT create a reply debt (see unansweredFor): one
|
|
48
|
+
* broadcast obliging eight replies is how a coordination channel becomes a spam channel, and
|
|
49
|
+
* that is the exact failure this room already had once.
|
|
50
|
+
*/
|
|
51
|
+
export function mentionsAll(text) {
|
|
52
|
+
// CODE SPANS ARE STRIPPED FIRST, AND ONLY HERE. Every other false mention is handled by the
|
|
53
|
+
// boundary rules below plus `obligationTargets`, which refuses to open a debt against somebody
|
|
54
|
+
// who is not in the room. `@all` is the one case neither can save: it is not a member, so the
|
|
55
|
+
// membership gate does not apply to it, and it wakes EVERYONE. So writing `@all` inside
|
|
56
|
+
// backticks -- which is how anybody documents this feature -- paged the entire room, every time.
|
|
57
|
+
//
|
|
58
|
+
// This is deliberately NOT a Markdown parser. Nested spans, unclosed fences and indented blocks
|
|
59
|
+
// are all out of scope, because the question is not "what is code here" but the much smaller
|
|
60
|
+
// "is this one token wrapped in backticks". A tokenizer would be the wrong tool and a permanent
|
|
61
|
+
// source of edge cases; one replace is the whole fix.
|
|
62
|
+
return /@all(?![\w-])/i.test(stripCodeSpans(text))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Inline code spans blanked out, so a token quoted as code is not read as an address. */
|
|
66
|
+
const stripCodeSpans = (text) => String(text || '').replace(/`[^`]*`/g, ' ')
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* WHERE A MENTION MAY BEGIN, AND WHERE IT MAY NOT.
|
|
70
|
+
*
|
|
71
|
+
* Measured in room `night` on 20 Sep 2026, against the old bare /@([\w-]+)/g:
|
|
72
|
+
*
|
|
73
|
+
* "email me at ganesh@gmail.com" -> ["gmail"] every email address
|
|
74
|
+
* "blame line: author@example.org" -> ["example"] every commit trailer
|
|
75
|
+
* "npm i @modelcontextprotocol/sdk" -> ["modelcontextprotocol"] every scoped package
|
|
76
|
+
* "see `@all` in the docs" -> broadcast to everyone every doc about this feature
|
|
77
|
+
*
|
|
78
|
+
* After three hours the obligation ledger held 44 entries of which 32% were debts against members
|
|
79
|
+
* who had never existed, and a debt can only be discharged by the asker, so nothing in the system
|
|
80
|
+
* could ever have closed them. The ledger is what catches an agent going silent; it was becoming
|
|
81
|
+
* unreadable through ordinary use.
|
|
82
|
+
*
|
|
83
|
+
* LEADING: start of string, whitespace, an opening bracket, a quote, a dash, or a colon. Quotes
|
|
84
|
+
* are INCLUDED on purpose. A quoted example ("the parser reads "@antigravity"") is then extracted
|
|
85
|
+
* for the transcript and creates no debt, because `obligationTargets` gates on membership. The
|
|
86
|
+
* opposite choice would silently drop a real mention that opens a quoted sentence, and a dropped
|
|
87
|
+
* mention is the worse failure: the sender believes it was delivered.
|
|
88
|
+
*
|
|
89
|
+
* TRAILING: not a word character, a hyphen, or a SLASH. The slash is what distinguishes
|
|
90
|
+
* `@scope/package` from `@person`. It is written as one lookahead rather than a separate check so
|
|
91
|
+
* the regex cannot backtrack into a shorter name to satisfy it -- `[\w-]+` followed by `(?!\/)`
|
|
92
|
+
* alone would happily match "modelcontextprotoco" and declare victory.
|
|
93
|
+
*/
|
|
94
|
+
const MENTION_START = '(?:^|[\\s(\\[{<"\'—–:])'
|
|
95
|
+
const MENTION_END = '(?![\\w\\-/])'
|
|
96
|
+
|
|
36
97
|
/** Everyone addressed in a message, so a broadcast can be told from a direct call. */
|
|
37
98
|
export function mentionedNames(text) {
|
|
38
|
-
|
|
99
|
+
const re = new RegExp(`${MENTION_START}@([\\w-]+)${MENTION_END}`, 'g')
|
|
100
|
+
return [...new Set([...String(text || '').matchAll(re)].map((m) => m[1]))]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* WHO ACTUALLY OWES AN ANSWER — which is not the same question as who was named.
|
|
105
|
+
*
|
|
106
|
+
* Extraction and obligation were one decision and they are two. A transcript should show what the
|
|
107
|
+
* text said, including a name that is merely quoted; a DEBT is a claim about the present, and
|
|
108
|
+
* opening one against somebody who is not here produces an entry nobody can ever discharge, since
|
|
109
|
+
* only the asker may close it and the debtor does not exist.
|
|
110
|
+
*
|
|
111
|
+
* The distinction that makes this safe is which way time points. Resolving a HISTORICAL mention
|
|
112
|
+
* against who is present now would be the Historical Transcript Fallacy -- the past decided by the
|
|
113
|
+
* present -- and that is why extraction deliberately does NOT do it. An obligation is not history:
|
|
114
|
+
* it is live state about who owes me an answer right now, so checking present membership is
|
|
115
|
+
* exactly the right question for it.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} text
|
|
118
|
+
* @param {{ me?: string, present?: Iterable<string> }} ctx `present` is lowercased names
|
|
119
|
+
*/
|
|
120
|
+
export function obligationTargets(text, { me = '', present = [] } = {}) {
|
|
121
|
+
const here = new Set([...present].map((n) => String(n).toLowerCase()))
|
|
122
|
+
const self = String(me).toLowerCase()
|
|
123
|
+
return mentionedNames(text).filter((n) => {
|
|
124
|
+
const low = n.toLowerCase()
|
|
125
|
+
// @all wakes the room and obliges nobody -- one broadcast owing eight replies is how a
|
|
126
|
+
// coordination channel becomes a spam channel. Unchanged from the original behaviour.
|
|
127
|
+
if (low === 'all' || low === self) return false
|
|
128
|
+
return here.has(low)
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A NAME THAT CANNOT BE ADDRESSED IS NOT A NAME. Refuse it at the door.
|
|
134
|
+
*
|
|
135
|
+
* On 20 Sep 2026 an agent joined room `night` as "antigravity 1". `mentionedNames` above splits
|
|
136
|
+
* on /@([\w-]+)/g, so "@antigravity 1" reads as "@antigravity", and no such member existed. Its
|
|
137
|
+
* mail, its obligations and its wake-on-mention were all silently dead for an hour. It saw
|
|
138
|
+
* messages only because it happened to be tailing the chat log. Nothing errored; the room said
|
|
139
|
+
* "@antigravity is NOT in the room", which was true and useless, because the member WAS present
|
|
140
|
+
* under a name this parser cannot produce.
|
|
141
|
+
*
|
|
142
|
+
* WHY REFUSE RATHER THAN NORMALISE. Silently rewriting "coder 1" to "coder-1" collides with a
|
|
143
|
+
* real "coder-1" and merges two processes into one participant — one awareness entry, one cursor,
|
|
144
|
+
* one mailbox — with neither told. And the agent's own prompt still says its name is the original,
|
|
145
|
+
* so it stops recognising messages about itself.
|
|
146
|
+
*
|
|
147
|
+
* WHY REFUSE RATHER THAN RESOLVE @mentions AGAINST THE MEMBER LIST. That would make a departed
|
|
148
|
+
* member break the historical record: a fact about the past decided by who happens to be present
|
|
149
|
+
* now. Same defect as the receipts work in HIV-P2.
|
|
150
|
+
*
|
|
151
|
+
* WHY THIS CHARSET AND NOT A NICER ONE. It is not taste, it is arithmetic. The extractor above
|
|
152
|
+
* only recognises [\w-]. A name outside that set CANNOT be produced by it, however carefully the
|
|
153
|
+
* sender types. Constraining names to the charset the parser already uses makes the two
|
|
154
|
+
* consistent by construction instead of by coincidence.
|
|
155
|
+
*
|
|
156
|
+
* KNOWN LIMIT, stated because it is a product decision and not an oversight: `\w` is ASCII, so a
|
|
157
|
+
* name in Devanagari, Han or Cyrillic is refused. That is honest rather than good — those names
|
|
158
|
+
* are equally unaddressable today, and refusing at least says so out loud. Widening it means
|
|
159
|
+
* widening the extractor first; the two move together or not at all.
|
|
160
|
+
*
|
|
161
|
+
* @param {any} name
|
|
162
|
+
* @returns {{ ok: boolean, error?: string, suggested?: string }}
|
|
163
|
+
*/
|
|
164
|
+
export function validateMemberName(name) {
|
|
165
|
+
if (name == null || typeof name !== 'string' || name.length === 0) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
error: 'Member name must be a non-empty string so that the participant can be addressed via @mentions.',
|
|
169
|
+
suggested: 'agent-1',
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Whitespace is checked BEFORE the charset so the message names the actual problem. "contains
|
|
173
|
+
// whitespace" tells you what to do; "contains invalid characters" makes you hunt for which one.
|
|
174
|
+
if (/\s/.test(name)) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
error: `Member name "${name}" contains whitespace. Names with spaces cannot be addressed via @mentions, breaking message routing, mailboxes, and delivery receipts.`,
|
|
178
|
+
suggested: suggestMemberName(name),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (!/^[\w-]+$/.test(name)) {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
error: `Member name "${name}" contains invalid characters. Names must contain only letters, numbers, underscores, and hyphens so that @mentions can route mail to you.`,
|
|
185
|
+
suggested: suggestMemberName(name),
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { ok: true }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A refusal nobody can act on gets worked around, so every refusal above carries one of these.
|
|
193
|
+
*
|
|
194
|
+
* It deliberately validates its own output before returning it: a suggestion that is itself
|
|
195
|
+
* invalid is worse than no suggestion, because the caller retries, fails again, and now distrusts
|
|
196
|
+
* the error message. Whitespace-only and non-ASCII names both collapse to nothing here, which is
|
|
197
|
+
* why the fallback exists rather than being defensive padding.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} name
|
|
200
|
+
* @returns {string}
|
|
201
|
+
*/
|
|
202
|
+
export function suggestMemberName(name) {
|
|
203
|
+
if (!name || typeof name !== 'string') return 'agent-1'
|
|
204
|
+
let s = name.replace(/[\s]+/g, '-').replace(/[^\w-]+/g, '-')
|
|
205
|
+
s = s.replace(/-+/g, '-').replace(/^[-_]+|[-_]+$/g, '')
|
|
206
|
+
return /^[\w-]+$/.test(s) ? s : 'agent-1'
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Who has directly addressed `me` and not been answered since?
|
|
211
|
+
*
|
|
212
|
+
* "Answered" is deliberately cheap to satisfy: any later message from me that names them. It is
|
|
213
|
+
* not trying to judge whether the reply was any good -- only that the room never goes silent on
|
|
214
|
+
* someone who asked a direct question, which is the failure that makes agents give up on the
|
|
215
|
+
* channel and start tailing files instead.
|
|
216
|
+
*
|
|
217
|
+
* Derived from the chat log rather than stored, so it cannot drift out of sync with what was
|
|
218
|
+
* actually said, and needs no new state to persist or migrate.
|
|
219
|
+
*
|
|
220
|
+
* THREE THINGS DO NOT COUNT AS A DEBT, all for the same reason -- an obligation that fires on
|
|
221
|
+
* traffic nobody chose to send is noise wearing a rule's clothes:
|
|
222
|
+
* - `@all` broadcasts (see mentionsAll)
|
|
223
|
+
* - system messages, and the client's own auto-posted conflict/adopt notices
|
|
224
|
+
* - my own messages, however many times I say my own name
|
|
225
|
+
*
|
|
226
|
+
* @param {Array<{by?: string, kind?: string, text?: string, at?: string}>} messages
|
|
227
|
+
* @param {string} me
|
|
228
|
+
* @returns {Array<{from: string, at: string, text: string}>} oldest first
|
|
229
|
+
*/
|
|
230
|
+
export function unansweredFor(messages, me) {
|
|
231
|
+
const list = Array.isArray(messages) ? messages : []
|
|
232
|
+
if (!me) return []
|
|
233
|
+
const low = (s) => String(s || '').toLowerCase()
|
|
234
|
+
const mine = low(me)
|
|
235
|
+
const AUTO = /^(⚠ MERGE CONFLICT|✓ conflict|↓ )/ // posted by the sync client, not by a person
|
|
236
|
+
|
|
237
|
+
const owed = new Map() // sender (lowercased) -> the message I owe a reply to
|
|
238
|
+
for (const m of list) {
|
|
239
|
+
if (!m || m.kind === 'system' || AUTO.test(String(m.text || ''))) continue
|
|
240
|
+
if (low(m.by) === mine) {
|
|
241
|
+
// Anything I name is discharged. A broadcast from me discharges everything: I have
|
|
242
|
+
// spoken to the room, and holding a debt open after that is pedantry, not coordination.
|
|
243
|
+
if (mentionsAll(m.text)) { owed.clear(); continue }
|
|
244
|
+
for (const n of mentionedNames(m.text)) owed.delete(low(n))
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
// Directly addressed, not merely broadcast to.
|
|
248
|
+
if (mentionsAll(m.text)) continue
|
|
249
|
+
if (mentionsName(m.text, me)) owed.set(low(m.by), { from: m.by, at: m.at || '', text: m.text || '' })
|
|
250
|
+
}
|
|
251
|
+
return [...owed.values()]
|
|
39
252
|
}
|
|
40
253
|
|
|
41
254
|
/**
|