@cmgmyr/hive 1.3.0 → 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/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,190 @@ 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: "global",
343
+ actor_prune: "global",
344
+ "hive lead": "spin_up",
345
+ "hive queen-audit": "read",
346
+ "hive init": "home",
347
+ "hive attach": "home",
348
+ "hive next": "home",
349
+ "hive start": "home",
350
+ "hive stop": "home",
351
+ "hive show": "home",
352
+ "hive hide": "home",
353
+ "hive pad --save": "home",
354
+ };
355
+ export const QUEEN_REFUSAL = "QUEEN_CROSS_PROJECT_WRITE_REFUSED";
356
+ function runningLead(projectId) {
357
+ return db
358
+ .prepare("SELECT id, actor_id, name FROM agents WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id LIMIT 1")
359
+ .get(projectId, LEAD_KIND);
360
+ }
361
+ function namesLead(ref, lead) {
362
+ return typeof ref === "number" ? ref === lead.id : ref === lead.name;
363
+ }
364
+ function foreignWriteProblem(reach, targetProjectId, args) {
365
+ switch (reach) {
366
+ case "spin_up":
367
+ case "todo":
368
+ return null;
369
+ case "lead_wake": {
370
+ if (args.scope !== undefined)
371
+ return "a standing watch in another project belongs to its lead";
372
+ const lead = runningLead(targetProjectId);
373
+ if (!lead)
374
+ return "that project has no running lead to address";
375
+ if (!namesLead(args.deliver_to, lead)) {
376
+ return `deliver_to must name that project's running lead ("${lead.name}" or agent id ${lead.id})`;
377
+ }
378
+ return null;
379
+ }
380
+ case "owned_lead_wake": {
381
+ const lead = runningLead(targetProjectId);
382
+ if (!lead)
383
+ return "that project has no running lead, so no wake of yours there is still addressed to one";
384
+ const wake = db
385
+ .prepare("SELECT owner, deliver_actor FROM wakes WHERE id = ? AND project_id = ?")
386
+ .get(args.wake_id, targetProjectId);
387
+ if (!wake || wake.owner !== process.env.HIVE_AGENT_ID || wake.deliver_actor !== lead.actor_id) {
388
+ return "only a wake you set there, addressed to that project's current running lead, can be changed";
389
+ }
390
+ return null;
391
+ }
392
+ case "lead_text": {
393
+ if (args.keys !== undefined)
394
+ return "keys can interrupt a lead mid-turn; send text instead";
395
+ const lead = runningLead(targetProjectId);
396
+ if (!lead)
397
+ return "that project has no running lead to address";
398
+ if (!namesLead(args.agent_id ?? args.name, lead)) {
399
+ return `the recipient must be that project's running lead ("${lead.name}" or agent id ${lead.id})`;
400
+ }
401
+ return null;
402
+ }
403
+ case "home":
404
+ return "that belongs to the project's own lead";
405
+ case "select":
406
+ return "a selection would redirect every later call that omits project_id; pass project_id on each call instead";
407
+ }
408
+ }
409
+ // targetProjectId null means the operation has no single project (a store-wide sweep, an unregistered path).
410
+ export function assertQueenCrossProjectWrite(operation, targetProjectId, args) {
411
+ const home = queenProjectId();
412
+ if (home === null)
413
+ return;
414
+ const reach = QUEEN_REACH[operation];
415
+ if (reach === "read")
416
+ return;
417
+ const problem = reach === undefined
418
+ ? "hive has not classified this operation for the queen, so it refuses by default"
419
+ : reach === "global"
420
+ ? "it changes the whole store, not one project"
421
+ : targetProjectId === home
422
+ ? null
423
+ : targetProjectId === null
424
+ ? "it names no registered project"
425
+ : foreignWriteProblem(reach, targetProjectId, args);
426
+ if (problem)
427
+ throw queenRefusal(operation, targetProjectId, problem);
428
+ }
429
+ export function assertQueenToolCall(tool, args) {
430
+ if (!isQueenLead())
431
+ return;
432
+ const reach = QUEEN_REACH[tool];
433
+ if (reach === "read")
434
+ return;
435
+ const override = typeof args.project_id === "number" ? args.project_id : undefined;
436
+ const target = reach === undefined || reach === "global" ? null : effectiveProjectId(override);
437
+ assertQueenCrossProjectWrite(tool, target, args);
438
+ }
439
+ function queenRefusal(operation, targetProjectId, problem) {
440
+ const target = targetProjectId === null ? null : getProject(targetProjectId);
441
+ const where = target ? `in project ${target.id} ("${target.name}")` : "outside the queen's own project";
442
+ return new Error(`${QUEEN_REFUSAL}: the queen cannot run ${operation} ${where}: ${problem}. Into another project the ` +
443
+ "queen may only todo_create, todo_comment, send text or wakes to its running lead, and start its lead " +
444
+ "with `hive lead <path>`. Everything else there is its lead's to do.");
445
+ }
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
- retryOnBusy(() => {
415
- db.exec(`CREATE TABLE IF NOT EXISTS migrations (
416
- version INTEGER PRIMARY KEY,
417
- applied_at TEXT NOT NULL DEFAULT (datetime('now'))
418
- )`);
419
- db.exec(`CREATE TABLE IF NOT EXISTS backup_meta (
420
- id INTEGER PRIMARY KEY CHECK (id = 1),
421
- last_attempt_at TEXT,
422
- last_success_at TEXT,
423
- last_error TEXT,
424
- last_error_at TEXT
425
- )`);
426
- db.prepare("INSERT OR IGNORE INTO backup_meta (id) VALUES (1)").run();
427
- }, "preparing the store's bookkeeping tables");
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
- maybeBackupBeforeMigrations(db, dataDir, MIGRATIONS.length - before.size);
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
  }
@@ -0,0 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+ export const FIRST_MESSAGE_SHA_ENV = "HIVE_LEAD_FIRST_MESSAGE_SHA";
3
+ export function firstMessageDigest(message) {
4
+ return createHash("sha256").update(message.trim()).digest("hex");
5
+ }
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
- initialPromptArgs: null,
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 work areas
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 editing a shared file area; leases
101
- expire so a dead session never wedges anyone.`,
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?, max_wait_seconds?,
199
- deliver_to?) — fire when workers go idle. Pass exactly one of:
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 work areas
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
- "file:src/api/routes.ts" or "todo:42". Leases expire on their own, so a
354
- crashed session never wedges the team.`,
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/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 work areas, 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.
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 shared work areas and expire on their own.
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
- 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.`,
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
- // Exported so src/cli.ts can hand the identical text to a codex lead as its initial CLI prompt -
100
- // codex's SessionStart hook rejects the whole payload if this rides inside hookSpecificOutput
101
- // (see the `forCodex` branch below), so a codex lead gets it a different way, not a different
102
- // message. Keep the two in sync by construction rather than by two literals staying equal.
103
- export const TRIAGE_MESSAGE = "Start with morning triage. Run `hive runbook` for this project's standing process, then " +
104
- "reconcile the state hive just injected against what is really there (agent_list, todo_list, " +
105
- "wake_list) and report it in a few lines. Propose today's lanes and confirm them with me before " +
106
- "dispatching anything.";
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 { activeProfile, DEFAULT_LEAD_BRANCHES, loadProjectYml } = await import("./projectYml.js");
119
- const { profileExists } = await import("./profiles.js");
120
- const { config, warnings } = loadProjectYml(dir);
121
- const silent = (reason) => ({ fired: false, reason, warnings });
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 silent("not a registered hive project root");
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
- ...(opts.forCodex ? {} : { initialUserMessage: TRIAGE_MESSAGE }),
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");