@cmgmyr/hive 1.3.0 → 1.5.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 +1 -1
- package/dist/brief.js +1 -1
- package/dist/build-info.json +1 -1
- package/dist/cli.js +419 -56
- package/dist/context.js +193 -2
- package/dist/db.js +61 -18
- package/dist/firstMessage.js +5 -0
- package/dist/harnesses.js +6 -4
- package/dist/help.js +32 -11
- package/dist/hook.js +9 -0
- package/dist/index.js +11 -5
- package/dist/kickoff.js +41 -27
- package/dist/leadState.js +63 -0
- package/dist/leadWatch.js +73 -0
- package/dist/portfolio.js +210 -0
- package/dist/projectRemove.js +68 -0
- package/dist/projectYml.js +19 -0
- package/dist/queenAudit.js +132 -0
- package/dist/queenDashboard.js +573 -0
- package/dist/scheduler.js +179 -15
- package/dist/strictInput.js +24 -7
- package/dist/tmux.js +82 -6
- package/dist/tools/agents.js +4 -0
- package/dist/tools/leases.js +2 -2
- package/dist/tools/meta.js +51 -19
- package/dist/tools/queenAudit.js +13 -0
- package/dist/tools/todos.js +18 -13
- package/dist/tools/wakes.js +197 -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
package/dist/context.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { realpathSync } from "node:fs";
|
|
2
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
3
3
|
import { userInfo } from "node:os";
|
|
4
|
-
import { basename, dirname, resolve, sep } from "node:path";
|
|
4
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
5
|
+
import { storeDir } from "./dataDir.js";
|
|
5
6
|
import { db } from "./db.js";
|
|
7
|
+
import { LEAD_KIND } from "./spawn.js";
|
|
6
8
|
let selectedId = null;
|
|
7
9
|
let cachedActorId = null;
|
|
8
10
|
let lastTouchMs = 0;
|
|
@@ -254,3 +256,192 @@ export function trySelectedProject() {
|
|
|
254
256
|
return null;
|
|
255
257
|
}
|
|
256
258
|
}
|
|
259
|
+
export const QUEEN_PROFILE = "queen";
|
|
260
|
+
export function queenHomeDir() {
|
|
261
|
+
return join(storeDir(), "queen");
|
|
262
|
+
}
|
|
263
|
+
function queenHomeProject() {
|
|
264
|
+
const home = queenHomeDir();
|
|
265
|
+
try {
|
|
266
|
+
if (lstatSync(home).isSymbolicLink())
|
|
267
|
+
return null;
|
|
268
|
+
return getProjectByPath(realpathSync(home)) ?? null;
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function queenProjectId() {
|
|
275
|
+
if (projectLock)
|
|
276
|
+
return null;
|
|
277
|
+
const actorId = process.env.HIVE_AGENT_ID;
|
|
278
|
+
if (!actorId)
|
|
279
|
+
return null;
|
|
280
|
+
const home = queenHomeProject();
|
|
281
|
+
if (!home)
|
|
282
|
+
return null;
|
|
283
|
+
const row = db
|
|
284
|
+
.prepare("SELECT 1 FROM agents WHERE actor_id = ? AND project_id = ? AND kind = ? AND status = 'running'")
|
|
285
|
+
.get(actorId, home.id, LEAD_KIND);
|
|
286
|
+
return row ? home.id : null;
|
|
287
|
+
}
|
|
288
|
+
export function isQueenLead() {
|
|
289
|
+
return queenProjectId() !== null;
|
|
290
|
+
}
|
|
291
|
+
export function captureQueenWriteIdentity(targetProjectId) {
|
|
292
|
+
const home = queenProjectId();
|
|
293
|
+
if (home === null || home === targetProjectId)
|
|
294
|
+
return null;
|
|
295
|
+
return { actor_id: process.env.HIVE_AGENT_ID, home_project_id: home };
|
|
296
|
+
}
|
|
297
|
+
export const QUEEN_REACH = {
|
|
298
|
+
whoami: "read",
|
|
299
|
+
help: "read",
|
|
300
|
+
project_list: "read",
|
|
301
|
+
pad_list: "read",
|
|
302
|
+
pad_read: "read",
|
|
303
|
+
todo_list: "read",
|
|
304
|
+
todo_get: "read",
|
|
305
|
+
kv_get: "read",
|
|
306
|
+
kv_list: "read",
|
|
307
|
+
wake_get: "read",
|
|
308
|
+
wake_list: "read",
|
|
309
|
+
agent_list: "read",
|
|
310
|
+
agent_status: "read",
|
|
311
|
+
agent_output: "read",
|
|
312
|
+
agent_message_get: "read",
|
|
313
|
+
queen_audit_list: "read",
|
|
314
|
+
todo_create: "todo",
|
|
315
|
+
todo_comment: "todo",
|
|
316
|
+
wake_set: "lead_wake",
|
|
317
|
+
wake_when_idle: "lead_wake",
|
|
318
|
+
wake_update: "owned_lead_wake",
|
|
319
|
+
wake_cancel: "owned_lead_wake",
|
|
320
|
+
agent_send: "lead_text",
|
|
321
|
+
pad_write: "home",
|
|
322
|
+
pad_append: "home",
|
|
323
|
+
pad_edit: "home",
|
|
324
|
+
pad_archive: "home",
|
|
325
|
+
pad_delete: "home",
|
|
326
|
+
todo_update: "home",
|
|
327
|
+
todo_archive: "home",
|
|
328
|
+
todo_complete: "home",
|
|
329
|
+
todo_block: "home",
|
|
330
|
+
todo_unblock: "home",
|
|
331
|
+
kv_set: "home",
|
|
332
|
+
kv_delete: "home",
|
|
333
|
+
lease_acquire: "home",
|
|
334
|
+
lease_release: "home",
|
|
335
|
+
agent_spawn: "home",
|
|
336
|
+
agent_resume: "home",
|
|
337
|
+
agent_park: "home",
|
|
338
|
+
agent_rename: "home",
|
|
339
|
+
agent_close: "home",
|
|
340
|
+
project_select: "select",
|
|
341
|
+
project_add: "global",
|
|
342
|
+
project_prune: "prune",
|
|
343
|
+
actor_prune: "global",
|
|
344
|
+
"hive lead": "spin_up",
|
|
345
|
+
"hive queen-audit": "read",
|
|
346
|
+
"hive project rm": "prune",
|
|
347
|
+
"hive init": "home",
|
|
348
|
+
"hive attach": "home",
|
|
349
|
+
"hive next": "home",
|
|
350
|
+
"hive start": "home",
|
|
351
|
+
"hive stop": "home",
|
|
352
|
+
"hive show": "home",
|
|
353
|
+
"hive hide": "home",
|
|
354
|
+
"hive pad --save": "home",
|
|
355
|
+
};
|
|
356
|
+
export const QUEEN_REFUSAL = "QUEEN_CROSS_PROJECT_WRITE_REFUSED";
|
|
357
|
+
function runningLead(projectId) {
|
|
358
|
+
return db
|
|
359
|
+
.prepare("SELECT id, actor_id, name FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id LIMIT 1")
|
|
360
|
+
.get(projectId, LEAD_KIND);
|
|
361
|
+
}
|
|
362
|
+
function namesLead(ref, lead) {
|
|
363
|
+
return typeof ref === "number" ? ref === lead.id : ref === lead.name;
|
|
364
|
+
}
|
|
365
|
+
function foreignWriteProblem(reach, targetProjectId, args) {
|
|
366
|
+
switch (reach) {
|
|
367
|
+
case "spin_up":
|
|
368
|
+
case "todo":
|
|
369
|
+
case "prune":
|
|
370
|
+
return null;
|
|
371
|
+
case "lead_wake": {
|
|
372
|
+
if (args.scope !== undefined)
|
|
373
|
+
return "a standing watch in another project belongs to its lead";
|
|
374
|
+
const lead = runningLead(targetProjectId);
|
|
375
|
+
if (!lead)
|
|
376
|
+
return "that project has no running lead to address";
|
|
377
|
+
if (!namesLead(args.deliver_to, lead)) {
|
|
378
|
+
return `deliver_to must name that project's running lead ("${lead.name}" or agent id ${lead.id})`;
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
case "owned_lead_wake": {
|
|
383
|
+
const lead = runningLead(targetProjectId);
|
|
384
|
+
if (!lead)
|
|
385
|
+
return "that project has no running lead, so no wake of yours there is still addressed to one";
|
|
386
|
+
const wake = db
|
|
387
|
+
.prepare("SELECT owner, deliver_actor FROM wakes WHERE id = ? AND project_id = ?")
|
|
388
|
+
.get(args.wake_id, targetProjectId);
|
|
389
|
+
if (!wake || wake.owner !== process.env.HIVE_AGENT_ID || wake.deliver_actor !== lead.actor_id) {
|
|
390
|
+
return "only a wake you set there, addressed to that project's current running lead, can be changed";
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
case "lead_text": {
|
|
395
|
+
if (args.keys !== undefined)
|
|
396
|
+
return "keys can interrupt a lead mid-turn; send text instead";
|
|
397
|
+
const lead = runningLead(targetProjectId);
|
|
398
|
+
if (!lead)
|
|
399
|
+
return "that project has no running lead to address";
|
|
400
|
+
if (!namesLead(args.agent_id ?? args.name, lead)) {
|
|
401
|
+
return `the recipient must be that project's running lead ("${lead.name}" or agent id ${lead.id})`;
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
case "home":
|
|
406
|
+
return "that belongs to the project's own lead";
|
|
407
|
+
case "select":
|
|
408
|
+
return "a selection would redirect every later call that omits project_id; pass project_id on each call instead";
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// targetProjectId null means the operation has no single project (a store-wide sweep, an unregistered path).
|
|
412
|
+
export function assertQueenCrossProjectWrite(operation, targetProjectId, args) {
|
|
413
|
+
const home = queenProjectId();
|
|
414
|
+
if (home === null)
|
|
415
|
+
return;
|
|
416
|
+
const reach = QUEEN_REACH[operation];
|
|
417
|
+
if (reach === "read")
|
|
418
|
+
return;
|
|
419
|
+
const problem = reach === undefined
|
|
420
|
+
? "hive has not classified this operation for the queen, so it refuses by default"
|
|
421
|
+
: reach === "global"
|
|
422
|
+
? "it changes the whole store, not one project"
|
|
423
|
+
: targetProjectId === home
|
|
424
|
+
? null
|
|
425
|
+
: targetProjectId === null
|
|
426
|
+
? "it names no registered project"
|
|
427
|
+
: foreignWriteProblem(reach, targetProjectId, args);
|
|
428
|
+
if (problem)
|
|
429
|
+
throw queenRefusal(operation, targetProjectId, problem);
|
|
430
|
+
}
|
|
431
|
+
export function assertQueenToolCall(tool, args) {
|
|
432
|
+
if (!isQueenLead())
|
|
433
|
+
return;
|
|
434
|
+
const reach = QUEEN_REACH[tool];
|
|
435
|
+
if (reach === "read")
|
|
436
|
+
return;
|
|
437
|
+
const override = typeof args.project_id === "number" ? args.project_id : undefined;
|
|
438
|
+
const target = reach === undefined || reach === "global" ? null : effectiveProjectId(override);
|
|
439
|
+
assertQueenCrossProjectWrite(tool, target, args);
|
|
440
|
+
}
|
|
441
|
+
function queenRefusal(operation, targetProjectId, problem) {
|
|
442
|
+
const target = targetProjectId === null ? null : getProject(targetProjectId);
|
|
443
|
+
const where = target ? `in project ${target.id} ("${target.name}")` : "outside the queen's own project";
|
|
444
|
+
return new Error(`${QUEEN_REFUSAL}: the queen cannot run ${operation} ${where}: ${problem}. Into another project the ` +
|
|
445
|
+
"queen may only todo_create, todo_comment, send text or wakes to its running lead, start its lead " +
|
|
446
|
+
"with `hive lead <path>`, and run project_prune or `hive project rm` on it. Everything else there is its lead's to do.");
|
|
447
|
+
}
|
package/dist/db.js
CHANGED
|
@@ -372,6 +372,42 @@ CREATE INDEX idx_wakes_parent ON wakes(parent_wake_id) WHERE parent_wake_id IS N
|
|
|
372
372
|
`
|
|
373
373
|
ALTER TABLE agents ADD COLUMN model TEXT;
|
|
374
374
|
ALTER TABLE agents ADD COLUMN extra_args TEXT;
|
|
375
|
+
`,
|
|
376
|
+
`
|
|
377
|
+
CREATE TABLE lead_turn_state (
|
|
378
|
+
agent_id INTEGER PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
|
|
379
|
+
pane_pid TEXT NOT NULL DEFAULT '',
|
|
380
|
+
session_id TEXT NOT NULL DEFAULT '',
|
|
381
|
+
state TEXT NOT NULL DEFAULT 'unknown' CHECK (state IN ('unknown', 'working', 'idle')),
|
|
382
|
+
idle_seq INTEGER NOT NULL DEFAULT 0,
|
|
383
|
+
last_event TEXT NOT NULL DEFAULT '',
|
|
384
|
+
changed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
CREATE TABLE lead_idle_subscriptions (
|
|
388
|
+
wake_id INTEGER PRIMARY KEY REFERENCES wakes(id) ON DELETE CASCADE,
|
|
389
|
+
target_project_id INTEGER NOT NULL,
|
|
390
|
+
agent_id INTEGER NOT NULL,
|
|
391
|
+
pane_pid TEXT NOT NULL,
|
|
392
|
+
session_id TEXT NOT NULL DEFAULT '',
|
|
393
|
+
baseline_idle_seq INTEGER NOT NULL DEFAULT 0,
|
|
394
|
+
terminal_reason TEXT
|
|
395
|
+
);
|
|
396
|
+
`,
|
|
397
|
+
`
|
|
398
|
+
CREATE TABLE queen_audit (
|
|
399
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
400
|
+
actor_id TEXT NOT NULL,
|
|
401
|
+
home_project_id INTEGER NOT NULL,
|
|
402
|
+
target_project_id INTEGER NOT NULL,
|
|
403
|
+
operation TEXT NOT NULL,
|
|
404
|
+
resource_type TEXT NOT NULL,
|
|
405
|
+
resource_id INTEGER NOT NULL CHECK(resource_id > 0),
|
|
406
|
+
summary TEXT NOT NULL CHECK(length(summary) <= 160),
|
|
407
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
|
|
408
|
+
CHECK(home_project_id != target_project_id)
|
|
409
|
+
);
|
|
410
|
+
CREATE INDEX idx_queen_audit_target ON queen_audit(target_project_id, id);
|
|
375
411
|
`,
|
|
376
412
|
];
|
|
377
413
|
export function storeSchemaAhead(database) {
|
|
@@ -389,7 +425,7 @@ function readAppliedVersions() {
|
|
|
389
425
|
return new Set(db.prepare("SELECT version FROM migrations").all().map((r) => r.version));
|
|
390
426
|
}
|
|
391
427
|
const MIGRATE_LOCK_MAX_ATTEMPTS = 24;
|
|
392
|
-
function retryOnBusy(fn, label) {
|
|
428
|
+
function retryOnBusy(fn, label, pending) {
|
|
393
429
|
for (let attempt = 1;; attempt++) {
|
|
394
430
|
try {
|
|
395
431
|
return fn();
|
|
@@ -404,29 +440,36 @@ function retryOnBusy(fn, label) {
|
|
|
404
440
|
`wedged hive process and try again. Underlying error: ${errorMessage(e)}`);
|
|
405
441
|
}
|
|
406
442
|
else {
|
|
407
|
-
console.error(`hive: ${label} failed: ${errorMessage(e)}`);
|
|
443
|
+
console.error(`hive: ${label} failed: ${errorMessage(e)}${pending ? ` (${pending})` : ""}`);
|
|
408
444
|
}
|
|
409
445
|
process.exit(1);
|
|
410
446
|
}
|
|
411
447
|
}
|
|
412
448
|
}
|
|
449
|
+
function bookkeepingMissing() {
|
|
450
|
+
const names = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('migrations', 'backup_meta')").all().length;
|
|
451
|
+
return names < 2 || db.prepare("SELECT 1 FROM backup_meta WHERE id = 1").get() === undefined;
|
|
452
|
+
}
|
|
413
453
|
export function migrate() {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
454
|
+
if (bookkeepingMissing()) {
|
|
455
|
+
retryOnBusy(() => {
|
|
456
|
+
db.exec(`CREATE TABLE IF NOT EXISTS migrations (
|
|
457
|
+
version INTEGER PRIMARY KEY,
|
|
458
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
459
|
+
)`);
|
|
460
|
+
db.exec(`CREATE TABLE IF NOT EXISTS backup_meta (
|
|
461
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
462
|
+
last_attempt_at TEXT,
|
|
463
|
+
last_success_at TEXT,
|
|
464
|
+
last_error TEXT,
|
|
465
|
+
last_error_at TEXT
|
|
466
|
+
)`);
|
|
467
|
+
db.prepare("INSERT OR IGNORE INTO backup_meta (id) VALUES (1)").run();
|
|
468
|
+
}, "preparing the store's bookkeeping tables", "bookkeeping missing");
|
|
469
|
+
}
|
|
428
470
|
const before = readAppliedVersions();
|
|
429
|
-
|
|
471
|
+
const pendingCount = MIGRATIONS.length - before.size;
|
|
472
|
+
maybeBackupBeforeMigrations(db, dataDir, pendingCount);
|
|
430
473
|
const applyPending = db.transaction(() => {
|
|
431
474
|
const applied = readAppliedVersions();
|
|
432
475
|
MIGRATIONS.forEach((sql, i) => {
|
|
@@ -445,5 +488,5 @@ export function migrate() {
|
|
|
445
488
|
db.prepare("INSERT INTO migrations (version) VALUES (?)").run(version);
|
|
446
489
|
});
|
|
447
490
|
});
|
|
448
|
-
retryOnBusy(() => applyPending.immediate(), "applying pending migrations");
|
|
491
|
+
retryOnBusy(() => applyPending.immediate(), "applying pending migrations", pendingCount > 0 ? `${pendingCount} migration${pendingCount === 1 ? "" : "s"} pending` : undefined);
|
|
449
492
|
}
|
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
|
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
|
|
@@ -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
|
|
@@ -244,9 +252,13 @@ Every pad, todo, kv entry, and lease belongs to one project (a directory).
|
|
|
244
252
|
project_list — all registered projects plus the current selection
|
|
245
253
|
project_add(path?, name?) — register a directory (defaults to cwd)
|
|
246
254
|
project_select(project_id) — set this session's default scope
|
|
247
|
-
project_prune() — delete every registered project that owns no rows
|
|
255
|
+
project_prune(project_id?, confirm_name?) — 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.
|
|
259
|
+
With project_id and confirm_name (its exact name), removes it with every
|
|
260
|
+
row it owns after a snapshot; refuses while it has a running agent. From a
|
|
261
|
+
terminal: hive project rm <id|path> [--yes].
|
|
250
262
|
|
|
251
263
|
Resolution order: explicit project_id argument, then session selection, then
|
|
252
264
|
working-directory auto-detection. When the working directory matches no
|
|
@@ -262,7 +274,14 @@ Cross-project access is intentional-only. An empty result means there is
|
|
|
262
274
|
nothing in this project; report that and stop. Reach into another project
|
|
263
275
|
(project_select or a project_id override) only when the user explicitly
|
|
264
276
|
names it. Set HIVE_PROJECT_LOCK=1 in a session's environment to reject
|
|
265
|
-
cross-project access entirely; use it for worker sessions
|
|
277
|
+
cross-project access entirely; use it for worker sessions.
|
|
278
|
+
|
|
279
|
+
The one standing exception is the queen: the running lead of <data dir>/queen,
|
|
280
|
+
started by \`hive queen\`. It may read any registered project by project_id.
|
|
281
|
+
Into another project it may write only todo_create and todo_comment, plus
|
|
282
|
+
wake_set, wake_when_idle, wake_update, wake_cancel and agent_send text
|
|
283
|
+
addressed to that project's running lead. Every other write there refuses
|
|
284
|
+
with QUEEN_CROSS_PROJECT_WRITE_REFUSED. Workers the queen spawns stay locked.`,
|
|
266
285
|
identity: `IDENTITY — who is writing
|
|
267
286
|
|
|
268
287
|
Every write records an actor. Identity comes from environment variables read
|
|
@@ -343,13 +362,15 @@ todo_list without marking anything done.`,
|
|
|
343
362
|
|
|
344
363
|
Use kv for values another session needs to discover: current branch, a port,
|
|
345
364
|
a phase flag. Use pads for anything longer than a line or two.`,
|
|
346
|
-
leases: `LEASES — advisory expiring claims on shared
|
|
365
|
+
leases: `LEASES — advisory expiring claims on shared state
|
|
347
366
|
|
|
348
367
|
lease_acquire(key, ttl_seconds) — non-blocking; returns the holder if
|
|
349
368
|
already taken; re-taking your own lease extends it
|
|
350
369
|
lease_release(key)
|
|
351
370
|
|
|
352
371
|
Keys are project-scoped and free-form; keep them stable and specific, like
|
|
353
|
-
"
|
|
354
|
-
|
|
372
|
+
"checkout:main", "db:dev", "port:3000" or "todo:42". A lease tells other
|
|
373
|
+
sessions the state is in use; it enforces nothing, and it cannot prevent a
|
|
374
|
+
merge conflict between two branches. Leases expire on their own, so a crashed
|
|
375
|
+
session never wedges the team.`,
|
|
355
376
|
};
|
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/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
|
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { cutToUnitBudget } from "./slug.js";
|
|
5
|
+
import { FIRST_MESSAGE_SHA_ENV } from "./firstMessage.js";
|
|
5
6
|
export const OUTPUT_BUDGET = 10_000;
|
|
6
7
|
const CONTEXT_BUDGET = 6_000;
|
|
7
8
|
const BOARD_BUDGET = 1_800;
|
|
@@ -96,14 +97,26 @@ async function digest(projectPath, profile, warnings) {
|
|
|
96
97
|
lines.push("", "Standing process for this project: run `hive runbook`.");
|
|
97
98
|
return truncate(lines.join("\n"), CONTEXT_BUDGET);
|
|
98
99
|
}
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
+
}
|
|
107
120
|
export async function evaluate(cwd, opts = {}) {
|
|
108
121
|
if (process.env.HIVE_AGENT_ID && process.env.HIVE_LEAD !== "1") {
|
|
109
122
|
return { fired: false, reason: "worker session (HIVE_AGENT_ID is set)" };
|
|
@@ -115,33 +128,25 @@ export async function evaluate(cwd, opts = {}) {
|
|
|
115
128
|
catch {
|
|
116
129
|
return { fired: false, reason: "cwd does not exist" };
|
|
117
130
|
}
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
if (config == null)
|
|
123
|
-
return silent("no hive.yml here");
|
|
124
|
-
const profile = activeProfile(config);
|
|
125
|
-
if (!profile)
|
|
126
|
-
return silent("no profile in hive.yml");
|
|
127
|
-
if (!profileExists(profile))
|
|
128
|
-
return silent(`profile "${profile}" is not on this machine`);
|
|
129
|
-
const branch = currentBranch(dir);
|
|
130
|
-
const leadBranches = config?.lead_branches ?? DEFAULT_LEAD_BRANCHES;
|
|
131
|
-
if (branch != null && !leadBranches.includes(branch)) {
|
|
132
|
-
return silent(`branch "${branch}" is not a lead branch (${leadBranches.join(", ")})`);
|
|
133
|
-
}
|
|
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;
|
|
134
135
|
const context = await digest(dir, profile, warnings);
|
|
135
136
|
if (context == null)
|
|
136
|
-
return
|
|
137
|
+
return { fired: false, reason: "not a registered hive project root", warnings };
|
|
137
138
|
// Codex's SessionStartHookSpecificOutputWire is additionalProperties:false and permits only
|
|
138
139
|
// hookEventName/additionalContext - initialUserMessage inside it fails the whole payload
|
|
139
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;
|
|
140
145
|
const build = (text) => JSON.stringify({
|
|
141
146
|
hookSpecificOutput: {
|
|
142
147
|
hookEventName: "SessionStart",
|
|
143
148
|
additionalContext: text,
|
|
144
|
-
...(
|
|
149
|
+
...(hookMessage === null ? {} : { initialUserMessage: hookMessage }),
|
|
145
150
|
},
|
|
146
151
|
});
|
|
147
152
|
let payload = build(context);
|
|
@@ -149,7 +154,7 @@ export async function evaluate(cwd, opts = {}) {
|
|
|
149
154
|
const overflow = payload.length - OUTPUT_BUDGET;
|
|
150
155
|
payload = build(truncate(context, Math.max(0, context.length - overflow - 32)));
|
|
151
156
|
}
|
|
152
|
-
return { fired: true, payload, warnings };
|
|
157
|
+
return { fired: true, payload, warnings, firstMessage: first };
|
|
153
158
|
}
|
|
154
159
|
export async function runKickoff(argv = []) {
|
|
155
160
|
const explain = argv.includes("--explain");
|
|
@@ -167,6 +172,15 @@ export async function runKickoff(argv = []) {
|
|
|
167
172
|
for (const w of result.warnings ?? [])
|
|
168
173
|
console.log(`! hive.yml: ${w}`);
|
|
169
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
|
+
}
|
|
170
184
|
process.stdout.write(result.payload);
|
|
171
185
|
if (explain)
|
|
172
186
|
process.stdout.write("\n");
|