@cmgmyr/hive 1.2.2 → 1.4.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/README.md +6 -1
- package/claude-plugin/README.md +4 -1
- package/claude-plugin/kickoff.mjs +2 -1
- package/claude-plugin/skills/profile/SKILL.md +78 -0
- package/claude-plugin/skills/profile/references/interview.md +139 -0
- package/claude-plugin/skills/profile/references/recipes/index.md +12 -0
- package/claude-plugin/skills/profile/references/recipes/review-decision.md +41 -0
- package/claude-plugin/skills/profile/references/recipes/session-continuity.md +37 -0
- package/claude-plugin/skills/profile/references/recipes/shared-test-resource.md +45 -0
- package/claude-plugin/skills/profile/references/recipes/verification.md +50 -0
- package/claude-plugin/skills/profile/references/recipes/worker-context-checkpoint.md +51 -0
- package/dist/brief.js +1 -1
- package/dist/build-info.json +1 -1
- package/dist/cli.js +436 -112
- package/dist/codexHome.js +23 -8
- package/dist/config.js +8 -61
- package/dist/context.js +191 -2
- package/dist/db.js +80 -20
- package/dist/firstMessage.js +5 -0
- package/dist/globalConfig.js +247 -0
- package/dist/harnesses.js +14 -4
- package/dist/help.js +29 -11
- package/dist/hook.js +9 -0
- package/dist/hooks.js +2 -1
- package/dist/index.js +11 -5
- package/dist/kickoff.js +42 -29
- package/dist/leadState.js +63 -0
- package/dist/leadWatch.js +73 -0
- package/dist/portfolio.js +210 -0
- package/dist/projectYml.js +242 -178
- package/dist/queenAudit.js +118 -0
- package/dist/queenDashboard.js +573 -0
- package/dist/scheduler.js +190 -20
- package/dist/spawn.js +17 -4
- package/dist/strictInput.js +24 -7
- package/dist/tmux.js +65 -0
- package/dist/tools/agents.js +153 -15
- package/dist/tools/kv.js +24 -0
- package/dist/tools/leases.js +14 -2
- package/dist/tools/meta.js +65 -3
- package/dist/tools/pads.js +42 -0
- package/dist/tools/queenAudit.js +13 -0
- package/dist/tools/todos.js +72 -13
- package/dist/tools/wakes.js +233 -65
- package/package.json +4 -4
- package/profiles/orchestration/worker.md +1 -1
- package/profiles/queen/posture.md +31 -0
- package/profiles/queen/runbook.md +26 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmodSync, closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { isMap, parse, parseDocument } from "yaml";
|
|
5
|
+
import { storeDir } from "./dataDir.js";
|
|
6
|
+
import { errorMessage } from "./result.js";
|
|
7
|
+
export const ATTACH_MODES = ["auto", "raw", "control"];
|
|
8
|
+
export const AUTO_ATTACH_MODES = ["auto", "on", "off"];
|
|
9
|
+
export function isAttachMode(value) {
|
|
10
|
+
return ATTACH_MODES.includes(value);
|
|
11
|
+
}
|
|
12
|
+
export function isAutoAttach(value) {
|
|
13
|
+
return AUTO_ATTACH_MODES.includes(value);
|
|
14
|
+
}
|
|
15
|
+
export function globalConfigPath() {
|
|
16
|
+
return join(storeDir(), "hive.yml");
|
|
17
|
+
}
|
|
18
|
+
function isMapping(value) {
|
|
19
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
20
|
+
return false;
|
|
21
|
+
const prototype = Object.getPrototypeOf(value);
|
|
22
|
+
return prototype === Object.prototype || prototype === null;
|
|
23
|
+
}
|
|
24
|
+
export function isEmptyYamlDocument(text) {
|
|
25
|
+
return text.split(/\r?\n/).every((line) => /^\s*(?:#.*)?$/.test(line));
|
|
26
|
+
}
|
|
27
|
+
function readLayer(path, kind, warnings) {
|
|
28
|
+
let text;
|
|
29
|
+
try {
|
|
30
|
+
text = readFileSync(path, "utf8");
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code !== "ENOENT") {
|
|
34
|
+
warnings.push(`${path}: could not read configuration: ${errorMessage(error)}`);
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
let value;
|
|
39
|
+
try {
|
|
40
|
+
value = kind === "yaml" ? parse(text) : JSON.parse(text);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
warnings.push(`${path}: ${kind === "yaml" ? "hive.yml is not valid YAML" : "config.json is not valid JSON"}: ${errorMessage(error)}`);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (kind === "yaml" && value == null && isEmptyYamlDocument(text))
|
|
47
|
+
return null;
|
|
48
|
+
if (!isMapping(value)) {
|
|
49
|
+
warnings.push(`${path}: ${kind === "yaml" ? "configuration must be a YAML mapping." : "config.json must be a JSON object."}`);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
export function readGlobalConfig() {
|
|
55
|
+
const dir = storeDir();
|
|
56
|
+
const warnings = [];
|
|
57
|
+
const root = readLayer(join(dir, "hive.yml"), "yaml", warnings);
|
|
58
|
+
const legacy = readLayer(join(dir, "config.json"), "json", warnings) ?? {};
|
|
59
|
+
return { root, legacy, warnings };
|
|
60
|
+
}
|
|
61
|
+
function readEditableDocument(path, allowMissing) {
|
|
62
|
+
let text;
|
|
63
|
+
try {
|
|
64
|
+
text = readFileSync(path, "utf8");
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (allowMissing && error.code === "ENOENT")
|
|
68
|
+
return parseDocument("");
|
|
69
|
+
throw new Error(`${path}: could not read hive.yml: ${errorMessage(error)}`);
|
|
70
|
+
}
|
|
71
|
+
const document = parseDocument(text);
|
|
72
|
+
if (document.errors.length > 0) {
|
|
73
|
+
throw new Error(`${path}: hive.yml is not valid YAML: ${document.errors.map(errorMessage).join("; ")}`);
|
|
74
|
+
}
|
|
75
|
+
if (!isMap(document.contents))
|
|
76
|
+
throw new Error(`${path}: hive.yml must be a YAML mapping.`);
|
|
77
|
+
return document;
|
|
78
|
+
}
|
|
79
|
+
function writeAtomicYaml(path, text) {
|
|
80
|
+
let mode = 0o600;
|
|
81
|
+
try {
|
|
82
|
+
mode = statSync(path).mode & 0o777;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (error.code !== "ENOENT")
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
const candidate = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
89
|
+
let temp = null;
|
|
90
|
+
let fd = null;
|
|
91
|
+
try {
|
|
92
|
+
fd = openSync(candidate, "wx", 0o600);
|
|
93
|
+
temp = candidate;
|
|
94
|
+
writeFileSync(fd, text, { encoding: "utf8" });
|
|
95
|
+
closeSync(fd);
|
|
96
|
+
fd = null;
|
|
97
|
+
chmodSync(candidate, mode);
|
|
98
|
+
renameSync(candidate, path);
|
|
99
|
+
temp = null;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
if (fd !== null) {
|
|
103
|
+
try {
|
|
104
|
+
closeSync(fd);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (temp !== null) {
|
|
110
|
+
try {
|
|
111
|
+
unlinkSync(temp);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error.code !== "ENOENT")
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function updateGlobalDocument(dir, update) {
|
|
121
|
+
mkdirSync(dir, { recursive: true });
|
|
122
|
+
const path = join(dir, "hive.yml");
|
|
123
|
+
const document = readEditableDocument(path, true);
|
|
124
|
+
if (!update(document))
|
|
125
|
+
return false;
|
|
126
|
+
let text;
|
|
127
|
+
try {
|
|
128
|
+
text = document.toString();
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
throw new Error(`${path}: could not serialize hive.yml: ${errorMessage(error)}`);
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
writeAtomicYaml(path, text);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
throw new Error(`${path}: could not write hive.yml: ${errorMessage(error)}`);
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
export function writeGlobalConfigKey(key, value) {
|
|
142
|
+
const dir = storeDir();
|
|
143
|
+
updateGlobalDocument(dir, (document) => {
|
|
144
|
+
document.set(key, value);
|
|
145
|
+
return true;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
export function migrateLegacyConfig() {
|
|
149
|
+
const dir = storeDir();
|
|
150
|
+
const legacyPath = join(dir, "config.json");
|
|
151
|
+
const archivePath = `${legacyPath}.migrated`;
|
|
152
|
+
const globalPath = join(dir, "hive.yml");
|
|
153
|
+
const refusal = (path, warning) => ({
|
|
154
|
+
migrated: false,
|
|
155
|
+
notice: null,
|
|
156
|
+
warnings: [`${path}: ${warning}`],
|
|
157
|
+
});
|
|
158
|
+
let legacyText;
|
|
159
|
+
try {
|
|
160
|
+
legacyText = readFileSync(legacyPath, "utf8");
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (error.code === "ENOENT")
|
|
164
|
+
return { migrated: false, notice: null, warnings: [] };
|
|
165
|
+
return refusal(legacyPath, `could not read config.json: ${errorMessage(error)}`);
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
lstatSync(archivePath);
|
|
169
|
+
return refusal(legacyPath, `archive ${archivePath} already exists; refusing to overwrite it.`);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error.code !== "ENOENT") {
|
|
173
|
+
return refusal(archivePath, `could not check migration archive: ${errorMessage(error)}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
let legacy;
|
|
177
|
+
try {
|
|
178
|
+
const raw = JSON.parse(legacyText);
|
|
179
|
+
if (!isMapping(raw))
|
|
180
|
+
return refusal(legacyPath, "config.json must be a JSON object.");
|
|
181
|
+
legacy = raw;
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
return refusal(legacyPath, `config.json is not valid JSON: ${errorMessage(error)}`);
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
updateGlobalDocument(dir, (document) => {
|
|
188
|
+
let changed = false;
|
|
189
|
+
for (const [key, value] of Object.entries(legacy)) {
|
|
190
|
+
if (key === "review_tags" || document.has(key))
|
|
191
|
+
continue;
|
|
192
|
+
document.set(key, value);
|
|
193
|
+
changed = true;
|
|
194
|
+
}
|
|
195
|
+
return changed;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
const message = errorMessage(error);
|
|
200
|
+
const warning = message.startsWith(`${globalPath}:`)
|
|
201
|
+
? message
|
|
202
|
+
: `${globalPath}: could not update hive.yml: ${message}`;
|
|
203
|
+
return { migrated: false, notice: null, warnings: [warning] };
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
renameSync(legacyPath, archivePath);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
return refusal(legacyPath, `could not archive config.json as ${archivePath}: ${errorMessage(error)}`);
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
migrated: true,
|
|
213
|
+
notice: `hive: migrated ${legacyPath} into ${globalPath}; original saved as ${archivePath}.`,
|
|
214
|
+
warnings: [],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
export function resolveAttachSetting(key, global, legacy, warnings) {
|
|
218
|
+
const mode = key === "attach";
|
|
219
|
+
const env = mode
|
|
220
|
+
? isAttachMode(process.env.HIVE_ATTACH_MODE) ? process.env.HIVE_ATTACH_MODE : null
|
|
221
|
+
: process.env.HIVE_AUTO_ATTACH === "0"
|
|
222
|
+
? "off"
|
|
223
|
+
: isAutoAttach(process.env.HIVE_AUTO_ATTACH) ? process.env.HIVE_AUTO_ATTACH : null;
|
|
224
|
+
if (env != null)
|
|
225
|
+
return mode ? { mode: env, source: "env" } : { value: env, source: "env" };
|
|
226
|
+
const path = globalConfigPath();
|
|
227
|
+
if (global && Object.hasOwn(global, key)) {
|
|
228
|
+
const value = global[key];
|
|
229
|
+
if (value == null)
|
|
230
|
+
return mode ? { mode: "auto", source: "global" } : { value: "auto", source: "global" };
|
|
231
|
+
if (mode ? isAttachMode(value) : isAutoAttach(value)) {
|
|
232
|
+
return mode ? { mode: value, source: "global" } : { value: value, source: "global" };
|
|
233
|
+
}
|
|
234
|
+
warnings.push(`${path}: ${key} must be one of ${(mode ? ["auto", "raw", "control"] : ["auto", "on", "off"]).join(", ")}; ignoring "${String(value)}".`);
|
|
235
|
+
return mode ? { mode: "auto", source: "built-in" } : { value: "auto", source: "built-in" };
|
|
236
|
+
}
|
|
237
|
+
if (Object.hasOwn(legacy, key)) {
|
|
238
|
+
const value = legacy[key];
|
|
239
|
+
if (value == null)
|
|
240
|
+
return mode ? { mode: "auto", source: "global" } : { value: "auto", source: "global" };
|
|
241
|
+
if (mode ? isAttachMode(value) : isAutoAttach(value)) {
|
|
242
|
+
return mode ? { mode: value, source: "global" } : { value: value, source: "global" };
|
|
243
|
+
}
|
|
244
|
+
warnings.push(`${path.replace(/hive\.yml$/, "config.json")}: ${key} has an invalid value; ignoring it.`);
|
|
245
|
+
}
|
|
246
|
+
return mode ? { mode: "auto", source: "built-in" } : { value: "auto", source: "built-in" };
|
|
247
|
+
}
|
package/dist/harnesses.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { codexInputBoxState, codexPaneChoiceCheck, codexPaneHasInputBox, inputBoxState, paneChoiceCheck, paneHasInputBox, } from "./tmux.js";
|
|
1
|
+
import { claudeTrailerView, codexInputBoxState, codexTrailerView, codexPaneChoiceCheck, codexPaneHasInputBox, inputBoxState, paneChoiceCheck, paneHasInputBox, } from "./tmux.js";
|
|
2
2
|
export function commandHead(command) {
|
|
3
3
|
return command.trim().split(/\s+/)[0] ?? "";
|
|
4
4
|
}
|
|
@@ -52,10 +52,11 @@ const claudeHarness = {
|
|
|
52
52
|
supportsResume: true,
|
|
53
53
|
supportsRename: true,
|
|
54
54
|
classifiesPaneScreen: true,
|
|
55
|
-
paneClassifier: { choiceCheck: paneChoiceCheck, inputBoxState, hasInputBox: paneHasInputBox },
|
|
55
|
+
paneClassifier: { choiceCheck: paneChoiceCheck, inputBoxState, hasInputBox: paneHasInputBox, trailerView: claudeTrailerView },
|
|
56
56
|
hasScopes: true,
|
|
57
57
|
needsHome: false,
|
|
58
|
-
|
|
58
|
+
// "--" so a message that starts with a dash is never read as a flag.
|
|
59
|
+
initialPromptArgs: (message) => ["--", message],
|
|
59
60
|
// clear, other, and an unobserved value all stop nothing (todo 765; test/fixtures/hook-payloads/
|
|
60
61
|
// README.md). Only these two are what Claude Code documents as the session being over for good.
|
|
61
62
|
terminalSessionEndReasons: ["prompt_input_exit", "logout"],
|
|
@@ -79,11 +80,12 @@ export const codexHarness = {
|
|
|
79
80
|
choiceCheck: codexPaneChoiceCheck,
|
|
80
81
|
inputBoxState: codexInputBoxState,
|
|
81
82
|
hasInputBox: codexPaneHasInputBox,
|
|
83
|
+
trailerView: codexTrailerView,
|
|
82
84
|
},
|
|
83
85
|
hasScopes: false,
|
|
84
86
|
needsHome: true,
|
|
85
87
|
// Auto-submits with no Enter; verified live on v0.149.0.
|
|
86
|
-
initialPromptArgs: (message) => [message],
|
|
88
|
+
initialPromptArgs: (message) => ["--", message],
|
|
87
89
|
// Measured live on 0.151.0 (todo 782 S1): both `/quit` and Ctrl-C twice fire SessionEnd with
|
|
88
90
|
// reason "other" - never claude's `prompt_input_exit` or `logout`. Codex's vocabulary is
|
|
89
91
|
// disjoint from claude's, not a superset of it, so it gets its own set rather than joining
|
|
@@ -171,3 +173,11 @@ export function transcriptDirFor(command) {
|
|
|
171
173
|
export function hasTranscriptSignal(row) {
|
|
172
174
|
return transcriptDirFor(row.command) || row.transcript_path !== "";
|
|
173
175
|
}
|
|
176
|
+
export function readOnlyExtraArgsAllowed(harness, args = []) {
|
|
177
|
+
const allowlist = {
|
|
178
|
+
codex: { flag: "-c", value: /^model_reasoning_effort=(low|medium|high|xhigh)$/ },
|
|
179
|
+
claude: { flag: "--effort", value: /^(low|medium|high|xhigh|max)$/ },
|
|
180
|
+
};
|
|
181
|
+
const allowed = allowlist[harness];
|
|
182
|
+
return allowed != null && args.length % 2 === 0 && args.every((arg, index) => index % 2 === 0 ? arg === allowed.flag : allowed.value.test(arg));
|
|
183
|
+
}
|
package/dist/help.js
CHANGED
|
@@ -37,7 +37,7 @@ TODOS — shared task tracking with dependencies
|
|
|
37
37
|
KV — small shared status values (with optional TTL)
|
|
38
38
|
kv_set, kv_get, kv_list, kv_delete
|
|
39
39
|
|
|
40
|
-
LEASES — advisory expiring claims on shared
|
|
40
|
+
LEASES — advisory expiring claims on shared state
|
|
41
41
|
lease_acquire, lease_release
|
|
42
42
|
|
|
43
43
|
PROJECTS — scope management
|
|
@@ -97,8 +97,10 @@ Rules that keep this sane:
|
|
|
97
97
|
- Keep worker prompts self-contained: include pad and todo ids.
|
|
98
98
|
- Record every decision in the pad or a todo comment. Sessions die;
|
|
99
99
|
the store survives.
|
|
100
|
-
- Take a lease (lease_acquire) before
|
|
101
|
-
|
|
100
|
+
- Take a lease (lease_acquire) before changing state another session can
|
|
101
|
+
also change: a checkout two sessions share, a dev database, a port. Files
|
|
102
|
+
in a directory only you work in, such as your own git worktree, need
|
|
103
|
+
none. Leases expire so a dead session never wedges anyone.`,
|
|
102
104
|
profiles: `PROFILES — standing instructions shared across projects
|
|
103
105
|
|
|
104
106
|
A profile is a named set of instructions this project runs under, named by
|
|
@@ -126,7 +128,7 @@ If the human asks to change how work runs here, that is an edit to the
|
|
|
126
128
|
runbook (fork it first), not a pad write.`,
|
|
127
129
|
agents: `AGENTS — spawn and drive worker sessions in tmux
|
|
128
130
|
|
|
129
|
-
agent_spawn(name?, model?, command?, extra_args?, cwd?, placement?, layout?) —
|
|
131
|
+
agent_spawn(name?, model?, command?, extra_args?, read_only?, cwd?, placement?, layout?) —
|
|
130
132
|
start a worker (default command: claude) in session hive-main.
|
|
131
133
|
placement="split" (default) tiles the worker as a pane in the lead's
|
|
132
134
|
window so the whole crew shares one screen; placement="window" gives it
|
|
@@ -195,8 +197,9 @@ its turn), waiting (needs permission or input). Non-claude commands show
|
|
|
195
197
|
|
|
196
198
|
wake_set(delay_seconds, body, deliver_to?, repeat_every_seconds?) —
|
|
197
199
|
one-shot or repeating wake-up
|
|
198
|
-
wake_when_idle(agents | scope, body, mode?,
|
|
199
|
-
deliver_to?) — fire when workers go idle. Pass
|
|
200
|
+
wake_when_idle(agents | scope | lead_project_id, body, mode?,
|
|
201
|
+
max_wait_seconds?, deliver_to?) — fire when workers go idle. Pass
|
|
202
|
+
exactly one of:
|
|
200
203
|
scope="project" — a STANDING watch over the crew you spawn in this project.
|
|
201
204
|
It reports EACH worker as it finishes or its window dies, covers
|
|
202
205
|
workers spawned after you set it, and keeps watching until
|
|
@@ -209,6 +212,11 @@ its turn), waiting (needs permission or input). Non-claude commands show
|
|
|
209
212
|
others; mode=all fires when every watched agent is idle and returns
|
|
210
213
|
already_satisfied instead of scheduling if they already are.
|
|
211
214
|
Agents already idle when it was set do not count.
|
|
215
|
+
lead_project_id=<id> — QUEEN ONLY: a ONE-SHOT on another project's
|
|
216
|
+
running lead ENDING A TURN (not finishing its work). Stored in and
|
|
217
|
+
delivered to the queen's own project; refuses project_id and
|
|
218
|
+
deliver_to. A dead, reissued or restarted lead ends it with that
|
|
219
|
+
reason instead.
|
|
212
220
|
wake_get(wake_id) — read one wake-up by id, with its untruncated body
|
|
213
221
|
(wake_list truncates at 120 chars)
|
|
214
222
|
wake_update(wake_id, delay_seconds?, body?, repeat_every_seconds?) — edit
|
|
@@ -246,7 +254,8 @@ Every pad, todo, kv entry, and lease belongs to one project (a directory).
|
|
|
246
254
|
project_select(project_id) — set this session's default scope
|
|
247
255
|
project_prune() — delete every registered project that owns no rows
|
|
248
256
|
anywhere in the store, verified individually; never your own; refuses
|
|
249
|
-
under HIVE_PROJECT_LOCK=1 since it sweeps every project, not just yours
|
|
257
|
+
under HIVE_PROJECT_LOCK=1 since it sweeps every project, not just yours.
|
|
258
|
+
With project_id, removes only that project, and only if it owns no rows
|
|
250
259
|
|
|
251
260
|
Resolution order: explicit project_id argument, then session selection, then
|
|
252
261
|
working-directory auto-detection. When the working directory matches no
|
|
@@ -262,7 +271,14 @@ Cross-project access is intentional-only. An empty result means there is
|
|
|
262
271
|
nothing in this project; report that and stop. Reach into another project
|
|
263
272
|
(project_select or a project_id override) only when the user explicitly
|
|
264
273
|
names it. Set HIVE_PROJECT_LOCK=1 in a session's environment to reject
|
|
265
|
-
cross-project access entirely; use it for worker sessions
|
|
274
|
+
cross-project access entirely; use it for worker sessions.
|
|
275
|
+
|
|
276
|
+
The one standing exception is the queen: the running lead of <data dir>/queen,
|
|
277
|
+
started by \`hive queen\`. It may read any registered project by project_id.
|
|
278
|
+
Into another project it may write only todo_create and todo_comment, plus
|
|
279
|
+
wake_set, wake_when_idle, wake_update, wake_cancel and agent_send text
|
|
280
|
+
addressed to that project's running lead. Every other write there refuses
|
|
281
|
+
with QUEEN_CROSS_PROJECT_WRITE_REFUSED. Workers the queen spawns stay locked.`,
|
|
266
282
|
identity: `IDENTITY — who is writing
|
|
267
283
|
|
|
268
284
|
Every write records an actor. Identity comes from environment variables read
|
|
@@ -343,13 +359,15 @@ todo_list without marking anything done.`,
|
|
|
343
359
|
|
|
344
360
|
Use kv for values another session needs to discover: current branch, a port,
|
|
345
361
|
a phase flag. Use pads for anything longer than a line or two.`,
|
|
346
|
-
leases: `LEASES — advisory expiring claims on shared
|
|
362
|
+
leases: `LEASES — advisory expiring claims on shared state
|
|
347
363
|
|
|
348
364
|
lease_acquire(key, ttl_seconds) — non-blocking; returns the holder if
|
|
349
365
|
already taken; re-taking your own lease extends it
|
|
350
366
|
lease_release(key)
|
|
351
367
|
|
|
352
368
|
Keys are project-scoped and free-form; keep them stable and specific, like
|
|
353
|
-
"
|
|
354
|
-
|
|
369
|
+
"checkout:main", "db:dev", "port:3000" or "todo:42". A lease tells other
|
|
370
|
+
sessions the state is in use; it enforces nothing, and it cannot prevent a
|
|
371
|
+
merge conflict between two branches. Leases expire on their own, so a crashed
|
|
372
|
+
session never wedges the team.`,
|
|
355
373
|
};
|
package/dist/hook.js
CHANGED
|
@@ -179,6 +179,15 @@ try {
|
|
|
179
179
|
if (event === "prompt") {
|
|
180
180
|
db.prepare(`UPDATE agents SET resumed_at = '' WHERE actor_id = ? AND kind = 'agent' AND ${awaitingFirstPromptSql("agents")}`).run(actorId);
|
|
181
181
|
}
|
|
182
|
+
// A lead's turn state is its own table; agents.agent_state stays worker-only.
|
|
183
|
+
if (process.env.HIVE_LEAD === "1" && (event === "prompt" || event === "stop" || event === "session_end")) {
|
|
184
|
+
try {
|
|
185
|
+
const { applyLeadHook } = await import("./leadState.js");
|
|
186
|
+
applyLeadHook(actorId, event, readPayload(), () => waitingOnSubagents(actorId, readPayload()));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
}
|
|
190
|
+
}
|
|
182
191
|
if (event === "session_end") {
|
|
183
192
|
try {
|
|
184
193
|
await stopProcessesForEndedLead(actorId, readPayload());
|
package/dist/hooks.js
CHANGED
|
@@ -39,7 +39,8 @@ export function ensureWorkerHooksFile(agentId, options) {
|
|
|
39
39
|
const settings = {
|
|
40
40
|
...stateHookSettings(),
|
|
41
41
|
statusLine: statusLineEntry(effectiveClaudeStatusLine(worker.cwd)),
|
|
42
|
-
permissions: { deny: ["SendMessage", "ListAgents"] },
|
|
42
|
+
permissions: { deny: ["SendMessage", "ListAgents", ...(options.readOnly ? ["Edit", "Write", "NotebookEdit"] : [])] },
|
|
43
|
+
...(options.readOnly ? { sandbox: { enabled: true, failIfUnavailable: true, autoAllowBashIfSandboxed: true, allowUnsandboxedCommands: false, filesystem: { denyWrite: ["//"] } } } : {}),
|
|
43
44
|
crossSessionInbound: "refuse",
|
|
44
45
|
};
|
|
45
46
|
if (options.includePostToolUse)
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { migrate } from "./db.js";
|
|
5
|
+
import { assertQueenToolCall, isQueenLead } from "./context.js";
|
|
5
6
|
import { registerAgents } from "./tools/agents.js";
|
|
6
7
|
import { registerMeta } from "./tools/meta.js";
|
|
7
8
|
import { registerPads } from "./tools/pads.js";
|
|
@@ -9,19 +10,23 @@ import { registerTodos } from "./tools/todos.js";
|
|
|
9
10
|
import { registerKv } from "./tools/kv.js";
|
|
10
11
|
import { registerLeases } from "./tools/leases.js";
|
|
11
12
|
import { registerWakes } from "./tools/wakes.js";
|
|
13
|
+
import { registerQueenAudit } from "./tools/queenAudit.js";
|
|
12
14
|
import { registerPrompts } from "./prompts.js";
|
|
13
15
|
import { schedulerIntervalMs, startScheduler } from "./scheduler.js";
|
|
14
16
|
import { enforceStrictInput } from "./strictInput.js";
|
|
17
|
+
migrate();
|
|
18
|
+
const SCOPE_DISCIPLINE = isQueenLead()
|
|
19
|
+
? "Scope discipline: you are the queen, the lead of hive's queen home. Your own project is the working directory's. You may read any registered project by passing its project_id. Into another project you may write only todo_create and todo_comment, plus wakes and agent_send text addressed to that project's running lead; every other write there refuses with QUEEN_CROSS_PROJECT_WRITE_REFUSED. See help(topic=\"projects\")."
|
|
20
|
+
: "Scope discipline: all state is project-scoped, and the current project is the working directory's. An empty result means there is nothing IN THIS PROJECT; report that and stop. Never browse other projects' state (project_id overrides, project_select) unless the user explicitly names another project and asks for it.";
|
|
15
21
|
const server = enforceStrictInput(new McpServer({ name: "hive", version: "0.1.0" }, {
|
|
16
|
-
instructions: `Hive gives Claude Code sessions shared, persistent state for coordinating work on a project: pads for plans and findings, todos for tracked work with blockers, kv for small shared values, leases for claiming shared
|
|
22
|
+
instructions: `Hive gives Claude Code sessions shared, persistent state for coordinating work on a project: pads for plans and findings, todos for tracked work with blockers, kv for small shared values, leases for claiming shared state, agents for spawning and directing other sessions, and wake-ups for scheduled or idle-triggered check-ins. Search for these tools when a task needs to persist past this conversation, hand off to or watch another session, or track multi-step work with dependencies.
|
|
17
23
|
Getting started:
|
|
18
24
|
1. Call whoami to see your actor identity and effective project scope.
|
|
19
25
|
2. Call help for an overview, or help(topic="workflow") for the lead/worker playbook.
|
|
20
|
-
3. Pads hold shared plans and findings. Todos coordinate work with blockers and comments. KV holds small shared values; leases claim
|
|
26
|
+
3. Pads hold shared plans and findings. Todos coordinate work with blockers and comments. KV holds small shared values; leases claim state more than one session can change and expire on their own.
|
|
21
27
|
4. Before triaging or orchestrating work, read the standing process: run \`hive runbook\` in a shell. It prints the project's profile runbook, or its runbook pad when the project has no profile. Do not read the pad first; most projects with a profile have no runbook pad at all.
|
|
22
|
-
|
|
23
|
-
}));
|
|
24
|
-
migrate();
|
|
28
|
+
${SCOPE_DISCIPLINE}`,
|
|
29
|
+
}), assertQueenToolCall);
|
|
25
30
|
registerMeta(server);
|
|
26
31
|
registerAgents(server);
|
|
27
32
|
registerPads(server);
|
|
@@ -29,6 +34,7 @@ registerTodos(server);
|
|
|
29
34
|
registerKv(server);
|
|
30
35
|
registerLeases(server);
|
|
31
36
|
registerWakes(server);
|
|
37
|
+
registerQueenAudit(server);
|
|
32
38
|
registerPrompts(server);
|
|
33
39
|
startScheduler(schedulerIntervalMs());
|
|
34
40
|
await server.connect(new StdioServerTransport());
|
package/dist/kickoff.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import { join } from "node:path";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
4
3
|
import { fileURLToPath } from "node:url";
|
|
5
4
|
import { cutToUnitBudget } from "./slug.js";
|
|
5
|
+
import { FIRST_MESSAGE_SHA_ENV } from "./firstMessage.js";
|
|
6
6
|
export const OUTPUT_BUDGET = 10_000;
|
|
7
7
|
const CONTEXT_BUDGET = 6_000;
|
|
8
8
|
const BOARD_BUDGET = 1_800;
|
|
@@ -97,14 +97,26 @@ async function digest(projectPath, profile, warnings) {
|
|
|
97
97
|
lines.push("", "Standing process for this project: run `hive runbook`.");
|
|
98
98
|
return truncate(lines.join("\n"), CONTEXT_BUDGET);
|
|
99
99
|
}
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
100
|
+
// Shared with `hive lead`, which passes the first message on the command line only when this holds.
|
|
101
|
+
export async function kickoffGate(dir) {
|
|
102
|
+
const { activeProfile, DEFAULT_LEAD_BRANCHES, loadProjectYml } = await import("./projectYml.js");
|
|
103
|
+
const { profileExists } = await import("./profiles.js");
|
|
104
|
+
const { config, warnings, sources } = loadProjectYml(dir);
|
|
105
|
+
const no = (reason) => ({ ok: false, reason, warnings });
|
|
106
|
+
if (config == null)
|
|
107
|
+
return no("no hive.yml here");
|
|
108
|
+
const profile = activeProfile(config);
|
|
109
|
+
if (!profile)
|
|
110
|
+
return no("no profile in hive.yml");
|
|
111
|
+
if (!profileExists(profile))
|
|
112
|
+
return no(`profile "${profile}" is not on this machine`);
|
|
113
|
+
const branch = currentBranch(dir);
|
|
114
|
+
const leadBranches = config.lead_branches ?? DEFAULT_LEAD_BRANCHES;
|
|
115
|
+
if (branch != null && !leadBranches.includes(branch)) {
|
|
116
|
+
return no(`branch "${branch}" is not a lead branch (${leadBranches.join(", ")})`);
|
|
117
|
+
}
|
|
118
|
+
return { ok: true, config, warnings, sources, profile };
|
|
119
|
+
}
|
|
108
120
|
export async function evaluate(cwd, opts = {}) {
|
|
109
121
|
if (process.env.HIVE_AGENT_ID && process.env.HIVE_LEAD !== "1") {
|
|
110
122
|
return { fired: false, reason: "worker session (HIVE_AGENT_ID is set)" };
|
|
@@ -116,33 +128,25 @@ export async function evaluate(cwd, opts = {}) {
|
|
|
116
128
|
catch {
|
|
117
129
|
return { fired: false, reason: "cwd does not exist" };
|
|
118
130
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const {
|
|
123
|
-
const { config, warnings } = loadProjectYml(dir);
|
|
124
|
-
const silent = (reason) => ({ fired: false, reason, warnings });
|
|
125
|
-
const profile = activeProfile(config);
|
|
126
|
-
if (!profile)
|
|
127
|
-
return silent("no profile in hive.yml");
|
|
128
|
-
if (!profileExists(profile))
|
|
129
|
-
return silent(`profile "${profile}" is not on this machine`);
|
|
130
|
-
const branch = currentBranch(dir);
|
|
131
|
-
const leadBranches = config?.lead_branches ?? DEFAULT_LEAD_BRANCHES;
|
|
132
|
-
if (branch != null && !leadBranches.includes(branch)) {
|
|
133
|
-
return silent(`branch "${branch}" is not a lead branch (${leadBranches.join(", ")})`);
|
|
134
|
-
}
|
|
131
|
+
const gate = await kickoffGate(dir);
|
|
132
|
+
if (!gate.ok)
|
|
133
|
+
return { fired: false, reason: gate.reason, warnings: gate.warnings };
|
|
134
|
+
const { config, warnings, sources, profile } = gate;
|
|
135
135
|
const context = await digest(dir, profile, warnings);
|
|
136
136
|
if (context == null)
|
|
137
|
-
return
|
|
137
|
+
return { fired: false, reason: "not a registered hive project root", warnings };
|
|
138
138
|
// Codex's SessionStartHookSpecificOutputWire is additionalProperties:false and permits only
|
|
139
139
|
// hookEventName/additionalContext - initialUserMessage inside it fails the whole payload
|
|
140
140
|
// ("SessionStart Failed"), silently dropping the board too. Measured, todo 567 comment 1864.
|
|
141
|
+
const { resolveFirstMessage } = await import("./projectYml.js");
|
|
142
|
+
const first = resolveFirstMessage(config, sources);
|
|
143
|
+
const onCommandLine = process.env.HIVE_LEAD === "1" && !!process.env[FIRST_MESSAGE_SHA_ENV];
|
|
144
|
+
const hookMessage = opts.forCodex || onCommandLine || first.message === "" ? null : first.message;
|
|
141
145
|
const build = (text) => JSON.stringify({
|
|
142
146
|
hookSpecificOutput: {
|
|
143
147
|
hookEventName: "SessionStart",
|
|
144
148
|
additionalContext: text,
|
|
145
|
-
...(
|
|
149
|
+
...(hookMessage === null ? {} : { initialUserMessage: hookMessage }),
|
|
146
150
|
},
|
|
147
151
|
});
|
|
148
152
|
let payload = build(context);
|
|
@@ -150,7 +154,7 @@ export async function evaluate(cwd, opts = {}) {
|
|
|
150
154
|
const overflow = payload.length - OUTPUT_BUDGET;
|
|
151
155
|
payload = build(truncate(context, Math.max(0, context.length - overflow - 32)));
|
|
152
156
|
}
|
|
153
|
-
return { fired: true, payload, warnings };
|
|
157
|
+
return { fired: true, payload, warnings, firstMessage: first };
|
|
154
158
|
}
|
|
155
159
|
export async function runKickoff(argv = []) {
|
|
156
160
|
const explain = argv.includes("--explain");
|
|
@@ -168,6 +172,15 @@ export async function runKickoff(argv = []) {
|
|
|
168
172
|
for (const w of result.warnings ?? [])
|
|
169
173
|
console.log(`! hive.yml: ${w}`);
|
|
170
174
|
if (result.fired && result.payload) {
|
|
175
|
+
if (explain && result.firstMessage) {
|
|
176
|
+
const { message, source } = result.firstMessage;
|
|
177
|
+
if (source === "unset")
|
|
178
|
+
console.log("first message: none (first_message is not set)");
|
|
179
|
+
else if (source === "empty")
|
|
180
|
+
console.log("first message: none (empty string in hive.yml)");
|
|
181
|
+
else
|
|
182
|
+
console.log(`first message (${source}): ${message}`);
|
|
183
|
+
}
|
|
171
184
|
process.stdout.write(result.payload);
|
|
172
185
|
if (explain)
|
|
173
186
|
process.stdout.write("\n");
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
import { FIRST_MESSAGE_SHA_ENV, firstMessageDigest } from "./firstMessage.js";
|
|
3
|
+
export function readLeadTurnState(agentId) {
|
|
4
|
+
return db.prepare("SELECT * FROM lead_turn_state WHERE agent_id = ?").get(agentId) ?? null;
|
|
5
|
+
}
|
|
6
|
+
function isHiveFirstMessage(payload) {
|
|
7
|
+
if (typeof payload.prompt !== "string")
|
|
8
|
+
return false;
|
|
9
|
+
const prompt = payload.prompt.trim();
|
|
10
|
+
const launched = process.env[FIRST_MESSAGE_SHA_ENV];
|
|
11
|
+
return !!launched && firstMessageDigest(prompt) === launched;
|
|
12
|
+
}
|
|
13
|
+
// "idle" means the lead's turn ended after a prompt hive did not type itself, in this pane launch
|
|
14
|
+
// and this session. It never means the lead finished all its work.
|
|
15
|
+
export function nextLeadTurn(cur, panePid, event, payload, subagentsLive) {
|
|
16
|
+
const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
|
|
17
|
+
if (sessionId === "")
|
|
18
|
+
return null;
|
|
19
|
+
const sameEpoch = cur !== null && cur.pane_pid === panePid && cur.session_id === sessionId;
|
|
20
|
+
const base = sameEpoch
|
|
21
|
+
? { pane_pid: panePid, session_id: sessionId, state: cur.state, idle_seq: cur.idle_seq }
|
|
22
|
+
: { pane_pid: panePid, session_id: sessionId, state: "unknown", idle_seq: cur?.idle_seq ?? 0 };
|
|
23
|
+
switch (event) {
|
|
24
|
+
case "prompt":
|
|
25
|
+
return isHiveFirstMessage(payload) ? base : { ...base, state: "working" };
|
|
26
|
+
case "stop":
|
|
27
|
+
if (base.state !== "working" || subagentsLive())
|
|
28
|
+
return base;
|
|
29
|
+
return { ...base, state: "idle", idle_seq: base.idle_seq + 1 };
|
|
30
|
+
case "session_end":
|
|
31
|
+
if (cur === null || cur.session_id !== sessionId)
|
|
32
|
+
return null;
|
|
33
|
+
return { pane_pid: cur.pane_pid, session_id: sessionId, state: "unknown", idle_seq: cur.idle_seq };
|
|
34
|
+
default:
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const applyInTransaction = db.transaction((agentId, panePid, event, payload, subagentsLive) => {
|
|
39
|
+
const cur = readLeadTurnState(agentId);
|
|
40
|
+
const next = nextLeadTurn(cur, panePid, event, payload, subagentsLive);
|
|
41
|
+
if (next === null)
|
|
42
|
+
return;
|
|
43
|
+
if (cur !== null &&
|
|
44
|
+
cur.pane_pid === next.pane_pid &&
|
|
45
|
+
cur.session_id === next.session_id &&
|
|
46
|
+
cur.state === next.state &&
|
|
47
|
+
cur.idle_seq === next.idle_seq) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
db.prepare(`INSERT INTO lead_turn_state (agent_id, pane_pid, session_id, state, idle_seq, last_event, changed_at)
|
|
51
|
+
VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
52
|
+
ON CONFLICT(agent_id) DO UPDATE SET pane_pid = excluded.pane_pid, session_id = excluded.session_id,
|
|
53
|
+
state = excluded.state, idle_seq = excluded.idle_seq, last_event = excluded.last_event,
|
|
54
|
+
changed_at = excluded.changed_at`).run(agentId, next.pane_pid, next.session_id, next.state, next.idle_seq, event);
|
|
55
|
+
});
|
|
56
|
+
export function applyLeadHook(actorId, event, payload, subagentsLive) {
|
|
57
|
+
const lead = db
|
|
58
|
+
.prepare("SELECT id, pane_pid FROM agents WHERE actor_id = ? AND kind = 'lead' AND status = 'running' ORDER BY id DESC LIMIT 1")
|
|
59
|
+
.get(actorId);
|
|
60
|
+
if (!lead)
|
|
61
|
+
return;
|
|
62
|
+
applyInTransaction.immediate(lead.id, lead.pane_pid, event, payload, subagentsLive);
|
|
63
|
+
}
|