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-gate.js
CHANGED
|
@@ -1,135 +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
|
-
}
|
|
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
|
+
}
|
package/hive-hook-install.js
CHANGED
|
@@ -1,103 +1,179 @@
|
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
import
|
|
25
|
-
import
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* INSTALL THE ANTI-IDLE GATE.
|
|
4
|
+
*
|
|
5
|
+
* npx -y -p hivecode-mcp@latest hivecode-hook install # this project (.claude/settings.json)
|
|
6
|
+
* npx -y -p hivecode-mcp@latest hivecode-hook install --global # every project (~/.claude/settings.json)
|
|
7
|
+
* npx -y -p hivecode-mcp@latest hivecode-hook status
|
|
8
|
+
* npx -y -p hivecode-mcp@latest 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. `setup` installs it globally
|
|
21
|
+
* (4 Oct 2026): it already configures every agent app on the machine, and an agent joins rooms from
|
|
22
|
+
* any folder, so a gate armed in one project would miss the rest.
|
|
23
|
+
*/
|
|
24
|
+
import fs from 'fs'
|
|
25
|
+
import os from 'os'
|
|
26
|
+
import path from 'path'
|
|
27
|
+
import { fileURLToPath } from 'url'
|
|
28
|
+
|
|
29
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
30
|
+
// Forward slashes: this string is written into JSON and handed to a shell. Windows accepts them
|
|
31
|
+
// and it avoids a backslash-escaping round trip through the settings file.
|
|
32
|
+
export const HOOK = path.join(HERE, 'hive-stop-hook.js').split(path.sep).join('/')
|
|
33
|
+
const COMMAND = `node "${HOOK}"`
|
|
34
|
+
const TIMEOUT = 10
|
|
35
|
+
|
|
36
|
+
// Ours is any Stop entry whose command mentions hive-stop-hook, so an install from a previous
|
|
37
|
+
// version (a different absolute path) is recognised and replaced rather than duplicated.
|
|
38
|
+
export const isOurs = (entry) =>
|
|
39
|
+
(entry?.hooks || []).some((h) => typeof h?.command === 'string' && h.command.includes('hive-stop-hook'))
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Put our Stop entry into `settingsPath`, leaving every other hook alone. Returns
|
|
43
|
+
* { status: 'installed' | 'updated' | 'unchanged' | 'refused', others, detail? }.
|
|
44
|
+
* A settings file we cannot parse is not ours to overwrite -- it is more likely hand-edited and
|
|
45
|
+
* briefly invalid than genuinely empty, and clobbering it would lose the user's own config.
|
|
46
|
+
*/
|
|
47
|
+
export function installStopHook(settingsPath, command = COMMAND, { dryRun = false } = {}) {
|
|
48
|
+
let s = {}
|
|
49
|
+
if (fs.existsSync(settingsPath)) {
|
|
50
|
+
try { s = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) } catch { s = null }
|
|
51
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) return { status: 'refused', others: 0, detail: `${settingsPath} exists but is not valid JSON` }
|
|
52
|
+
}
|
|
53
|
+
s.hooks = s.hooks && typeof s.hooks === 'object' ? s.hooks : {}
|
|
54
|
+
const stop = Array.isArray(s.hooks.Stop) ? s.hooks.Stop : []
|
|
55
|
+
const kept = stop.filter((e) => !isOurs(e))
|
|
56
|
+
const mine = stop.filter(isOurs)
|
|
57
|
+
const others = kept.length
|
|
58
|
+
if (mine.length === 1 && (mine[0].hooks || []).length === 1 && mine[0].hooks[0].command === command) return { status: 'unchanged', others }
|
|
59
|
+
kept.push({
|
|
60
|
+
hooks: [{ type: 'command', command, timeout: TIMEOUT, statusMessage: 'Checking the room (Hivecode)...' }],
|
|
61
|
+
})
|
|
62
|
+
s.hooks.Stop = kept
|
|
63
|
+
if (!dryRun) {
|
|
64
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true })
|
|
65
|
+
fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2) + '\n')
|
|
66
|
+
}
|
|
67
|
+
return { status: mine.length ? 'updated' : 'installed', others }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Put our Stop entry into Antigravity's `hooksPath` (JSON object keyed by hook name), leaving
|
|
72
|
+
* every other hook group alone. Returns { status: 'installed' | 'updated' | 'unchanged' | 'refused', others, detail? }.
|
|
73
|
+
*/
|
|
74
|
+
export function installAntigravityStopHook(hooksPath, command = `node "${HOOK}" --app=antigravity`, { dryRun = false } = {}) {
|
|
75
|
+
let s = {}
|
|
76
|
+
if (fs.existsSync(hooksPath)) {
|
|
77
|
+
try { s = JSON.parse(fs.readFileSync(hooksPath, 'utf8')) } catch { s = null }
|
|
78
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) return { status: 'refused', others: 0, detail: `${hooksPath} exists but is not valid JSON` }
|
|
79
|
+
}
|
|
80
|
+
const otherKeys = Object.keys(s).filter((k) => k !== 'hivecode')
|
|
81
|
+
const others = otherKeys.length
|
|
82
|
+
const current = s.hivecode && s.hivecode.Stop && s.hivecode.Stop[0] && s.hivecode.Stop[0].command
|
|
83
|
+
if (current === command) return { status: 'unchanged', others }
|
|
84
|
+
|
|
85
|
+
const had = !!s.hivecode
|
|
86
|
+
s.hivecode = {
|
|
87
|
+
Stop: [
|
|
88
|
+
{ type: 'command', command, timeout: TIMEOUT }
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
if (!dryRun) {
|
|
92
|
+
fs.mkdirSync(path.dirname(hooksPath), { recursive: true })
|
|
93
|
+
fs.writeFileSync(hooksPath, JSON.stringify(s, null, 2) + '\n')
|
|
94
|
+
}
|
|
95
|
+
return { status: had ? 'updated' : 'installed', others }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function cli() {
|
|
99
|
+
const cmd = (process.argv[2] || 'status').toLowerCase()
|
|
100
|
+
const GLOBAL = process.argv.includes('--global')
|
|
101
|
+
const isAntigravity = process.argv.includes('--app=antigravity')
|
|
102
|
+
const targetApp = isAntigravity ? 'Antigravity' : 'Claude Code'
|
|
103
|
+
const settingsPath = isAntigravity
|
|
104
|
+
? (GLOBAL ? path.join(os.homedir(), '.gemini', 'config', 'hooks.json') : path.join(process.cwd(), '.agents', 'hooks.json'))
|
|
105
|
+
: (GLOBAL ? path.join(os.homedir(), '.claude', 'settings.json') : path.join(process.cwd(), '.claude', 'settings.json'))
|
|
106
|
+
const command = isAntigravity ? `node "${HOOK}" --app=antigravity` : COMMAND
|
|
107
|
+
|
|
108
|
+
const read = () => {
|
|
109
|
+
try { return JSON.parse(fs.readFileSync(settingsPath, 'utf8')) } catch { return null }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function install() {
|
|
113
|
+
const r = isAntigravity
|
|
114
|
+
? installAntigravityStopHook(settingsPath, command)
|
|
115
|
+
: installStopHook(settingsPath, command)
|
|
116
|
+
if (r.status === 'refused') {
|
|
117
|
+
console.error(`refusing to touch ${settingsPath}: it exists but is not valid JSON.`)
|
|
118
|
+
console.error('fix or move it, then run this again.')
|
|
119
|
+
process.exit(1)
|
|
120
|
+
}
|
|
121
|
+
console.log(`\n ${r.status === 'installed' ? 'installed' : r.status === 'updated' ? 'updated' : 'already installed'} ${settingsPath}`)
|
|
122
|
+
console.log(` command ${command}`)
|
|
123
|
+
if (r.others) console.log(` alongside ${r.others} other ${isAntigravity ? 'hook group(s)' : 'Stop hook(s)'}, left untouched`)
|
|
124
|
+
console.log('\n An agent in a room can no longer end its turn while it has unread mail')
|
|
125
|
+
console.log(' addressed to it, an accepted task still open, or no listener running.\n')
|
|
126
|
+
console.log(` Restart ${targetApp} for it to pick this up.\n`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function uninstall() {
|
|
130
|
+
const s = read()
|
|
131
|
+
if (!s) { console.log(`\n not installed in ${settingsPath}\n`); return }
|
|
132
|
+
if (isAntigravity) {
|
|
133
|
+
if (!s.hivecode) { console.log(`\n not installed in ${settingsPath}\n`); return }
|
|
134
|
+
delete s.hivecode
|
|
135
|
+
fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2) + '\n')
|
|
136
|
+
console.log(`\n removed from ${settingsPath}\n`)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
const stop = Array.isArray(s.hooks?.Stop) ? s.hooks.Stop : []
|
|
140
|
+
const kept = stop.filter((e) => !isOurs(e))
|
|
141
|
+
if (kept.length === stop.length) { console.log(`\n not installed in ${settingsPath}\n`); return }
|
|
142
|
+
if (kept.length) s.hooks.Stop = kept; else delete s.hooks.Stop
|
|
143
|
+
if (s.hooks && !Object.keys(s.hooks).length) delete s.hooks
|
|
144
|
+
fs.writeFileSync(settingsPath, JSON.stringify(s, null, 2) + '\n')
|
|
145
|
+
console.log(`\n removed from ${settingsPath}\n`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function status() {
|
|
149
|
+
const s = read() || {}
|
|
150
|
+
if (isAntigravity) {
|
|
151
|
+
const mine = !!s.hivecode
|
|
152
|
+
const others = Object.keys(s).filter((k) => k !== 'hivecode').length
|
|
153
|
+
console.log(`\n hooks ${settingsPath}${fs.existsSync(settingsPath) ? '' : ' (does not exist)'}`)
|
|
154
|
+
console.log(` hivecode ${mine ? 'INSTALLED' : 'not installed'}`)
|
|
155
|
+
if (others) console.log(` other ${others} unrelated hook group(s)`)
|
|
156
|
+
console.log(`\n install with: npx -y -p hivecode-mcp@latest hivecode-hook install --app=antigravity${GLOBAL ? ' --global' : ''}\n`)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
const mine = (s.hooks?.Stop || []).filter(isOurs)
|
|
160
|
+
const others = (s.hooks?.Stop || []).length - mine.length
|
|
161
|
+
console.log(`\n settings ${settingsPath}${fs.existsSync(settingsPath) ? '' : ' (does not exist)'}`)
|
|
162
|
+
console.log(` hivecode ${mine.length ? 'INSTALLED' : 'not installed'}`)
|
|
163
|
+
if (others) console.log(` other ${others} unrelated Stop hook(s)`)
|
|
164
|
+
console.log(`\n install with: npx -y -p hivecode-mcp@latest hivecode-hook install${GLOBAL ? ' --global' : ''}\n`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (cmd === 'install') install()
|
|
168
|
+
else if (cmd === 'uninstall' || cmd === 'remove') uninstall()
|
|
169
|
+
else status()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Only as the command, so setup can import installStopHook without running a CLI on its argv.
|
|
173
|
+
// Resolved, because npm starts a bin through a symlink on macOS and Linux (hive-mcp.js, same check).
|
|
174
|
+
const me = fileURLToPath(import.meta.url)
|
|
175
|
+
const isMain = () => {
|
|
176
|
+
if (!process.argv[1]) return false
|
|
177
|
+
try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(me) } catch { return path.resolve(process.argv[1]) === path.resolve(me) }
|
|
178
|
+
}
|
|
179
|
+
if (isMain()) cli()
|