@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.
Files changed (48) hide show
  1. package/README.md +6 -1
  2. package/claude-plugin/README.md +4 -1
  3. package/claude-plugin/kickoff.mjs +2 -1
  4. package/claude-plugin/skills/profile/SKILL.md +78 -0
  5. package/claude-plugin/skills/profile/references/interview.md +139 -0
  6. package/claude-plugin/skills/profile/references/recipes/index.md +12 -0
  7. package/claude-plugin/skills/profile/references/recipes/review-decision.md +41 -0
  8. package/claude-plugin/skills/profile/references/recipes/session-continuity.md +37 -0
  9. package/claude-plugin/skills/profile/references/recipes/shared-test-resource.md +45 -0
  10. package/claude-plugin/skills/profile/references/recipes/verification.md +50 -0
  11. package/claude-plugin/skills/profile/references/recipes/worker-context-checkpoint.md +51 -0
  12. package/dist/brief.js +1 -1
  13. package/dist/build-info.json +1 -1
  14. package/dist/cli.js +436 -112
  15. package/dist/codexHome.js +23 -8
  16. package/dist/config.js +8 -61
  17. package/dist/context.js +191 -2
  18. package/dist/db.js +80 -20
  19. package/dist/firstMessage.js +5 -0
  20. package/dist/globalConfig.js +247 -0
  21. package/dist/harnesses.js +14 -4
  22. package/dist/help.js +29 -11
  23. package/dist/hook.js +9 -0
  24. package/dist/hooks.js +2 -1
  25. package/dist/index.js +11 -5
  26. package/dist/kickoff.js +42 -29
  27. package/dist/leadState.js +63 -0
  28. package/dist/leadWatch.js +73 -0
  29. package/dist/portfolio.js +210 -0
  30. package/dist/projectYml.js +242 -178
  31. package/dist/queenAudit.js +118 -0
  32. package/dist/queenDashboard.js +573 -0
  33. package/dist/scheduler.js +190 -20
  34. package/dist/spawn.js +17 -4
  35. package/dist/strictInput.js +24 -7
  36. package/dist/tmux.js +65 -0
  37. package/dist/tools/agents.js +153 -15
  38. package/dist/tools/kv.js +24 -0
  39. package/dist/tools/leases.js +14 -2
  40. package/dist/tools/meta.js +65 -3
  41. package/dist/tools/pads.js +42 -0
  42. package/dist/tools/queenAudit.js +13 -0
  43. package/dist/tools/todos.js +72 -13
  44. package/dist/tools/wakes.js +233 -65
  45. package/package.json +4 -4
  46. package/profiles/orchestration/worker.md +1 -1
  47. package/profiles/queen/posture.md +31 -0
  48. package/profiles/queen/runbook.md +26 -0
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync, spawn, spawnSync } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
- import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from "node:fs";
4
+ import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync, } from "node:fs";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { join, resolve, sep } from "node:path";
7
7
  import { createInterface } from "node:readline/promises";
@@ -9,19 +9,21 @@ import { pathToFileURL } from "node:url";
9
9
  import { checkAbi, describeAbi, describeInterpreter, nodeRangeForNodeApi, requiredNodeApi } from "./abi.js";
10
10
  import { versionInfo } from "./version.js";
11
11
  import { claudeConfigDir } from "./claudeDir.js";
12
- import { ATTACH_MODES, AUTO_ATTACH_MODES, attachMode, isAttachMode, isAutoAttach, resolvedAttachMode, resolvedAutoAttach, setAttachMode, setAutoAttach, } from "./config.js";
12
+ import { ATTACH_MODES, AUTO_ATTACH_MODES, attachMode, isAttachMode, isAutoAttach, migrateLegacyConfig, resolvedAutoAttach, setAttachMode, setAutoAttach, } from "./config.js";
13
13
  import { cliPath, dispatcherDir, dispatcherPath, dispatcherScript, durabilityLines, firstHiveOnPath, linkedWorktreePin, pathAdvice, readDispatcher, } from "./dispatcher.js";
14
14
  import { codexConfigPath, codexHiveRegistrations, codexRegistrationProblem, hiveRegistrations, registrationOffer, registrationProblem, } from "./mcpConfig.js";
15
- import { DEFAULT_DATA_DIR } from "./dataDir.js";
15
+ import { DEFAULT_DATA_DIR, storeDir } from "./dataDir.js";
16
16
  import { dataDir, db, migrate, storeSchemaAhead } from "./db.js";
17
17
  import { cacheIsStale, queryUpdate, readCachedUpdate, refreshUpdate, shouldAutoRefresh, updateLine } from "./updateCheck.js";
18
18
  import { checkoutUpgradeSteps, detectInstallShape, globalUpgradeSteps } from "./upgrade.js";
19
19
  import { isLowHeadroom, orphanLoginShellDetails, ptyHeadroom } from "./ptys.js";
20
- import { addProject, agentProjectPin, currentActor, effectiveProjectId, findProjectForCwd, getProjectByPath, gitPrimaryRoot, getProject, listProjects, registeredAncestor, takeRegistrationNotice, } from "./context.js";
20
+ import { addProject, agentProjectPin, assertQueenCrossProjectWrite, captureQueenWriteIdentity, currentActor, effectiveProjectId, findProjectForCwd, findProjectForDir, getProjectByPath, gitPrimaryRoot, getProject, listProjects, QUEEN_PROFILE, queenHomeDir, registeredAncestor, takeRegistrationNotice, } from "./context.js";
21
+ import { confirmQueenWrite, listQueenAudit } from "./queenAudit.js";
21
22
  import { ensureHooksFile } from "./hooks.js";
22
- import { errorMessage, parseTags, registrationNoticeText, withTrailingNewline } from "./result.js";
23
- import { ACTIVE_TIMER_WHERE, dashboardFileContained, describeStall, HELD_REASON_CONVERSATION, HELD_REASON_LEAD_PANE_DEAD, HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX, isUnclassifiablePaneHold, HELD_REASON_UNSUBMITTED_INPUT_PREFIX, isUnsubmittedInputHold, janitor, resolveDashboardFile, transcriptStaleness, wasHeldForPaneReissue, } from "./scheduler.js";
23
+ import { errorMessage, registrationNoticeText, withTrailingNewline } from "./result.js";
24
+ import { ACTIVE_TIMER_WHERE, dashboardFileContained, describeStall, HELD_REASON_CONVERSATION, HELD_REASON_LEAD_PANE_DEAD, HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX, isUnclassifiablePaneHold, HELD_REASON_UNSUBMITTED_INPUT_PREFIX, isUnsubmittedInputHold, janitor, resolveDashboardFile, generateQueenDashboardNow, transcriptStaleness, wasHeldForPaneReissue, } from "./scheduler.js";
24
25
  import { readTurnCount } from "./turnCount.js";
26
+ import { collectPortfolio, leadText } from "./portfolio.js";
25
27
  import { STALL_BOUND_SECONDS } from "./backgroundTasks.js";
26
28
  import { probeSessionInterpreter, reexecTarget, sessionStartVerdict, } from "./sessionProbe.js";
27
29
  import { backupHealth, backupNow, backupsDir, formatBytes, listSnapshots, previewRestore, restoreSnapshot, totalSizeBytes, } from "./backup.js";
@@ -30,15 +32,15 @@ import { asNameClash, buildEnvFlags, closeAgentRow, isReservedAgentName, launchA
30
32
  import { adoptableWindow, applyLayout, claimInitialWindow, configureHiveWindow, controlModeFor, createWindow, DEFAULT_LAYOUT, describePaneChoice, ensureSession, findProjectWindow, foreignSocket, isPaneTarget, isViewSessionName, listOwnedWindows, ORPHAN_MIN_AGE_MS, orphanScratchServers, orphansWorthWarningAbout, paneIsAloneInWindow, paneVisibility, panePid, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, projectWindows, RAW_ATTACH_TMUX_CONFIG, renderAttachCommand, resolveAttachTarget, resolveInTmuxTarget, paneReissued, rowLive, rowLiveProbe, SESSION_PREFIX, appendGlobalHook, globalHooks, replaceGlobalHooks, sessionName, setPaneTitle, shellQuote, shownPaneTitle, makeProcessesWindow, tmux, TMUX_DOC, tmuxSaysNothingThere, tmuxTimeoutOverride, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, } from "./tmux.js";
31
33
  import { describeVisibility, processCounts } from "./dashboard.js";
32
34
  import { runningCommandRow, runningCommandRows, snapshotProcesses, stopAllProcesses, stopLine, stopProcess, STOP_REASONS, } from "./processes.js";
33
- import { activeProfile, agentVarKeys, configHash, loadProjectYml, mergedProjectVars, NO_PROFILE, resolveCommandDir, } from "./projectYml.js";
35
+ import { activeProfile, agentVarKeys, BUILT_IN_PROJECT_YML, configHash, loadProjectYml, mergedProjectVars, NO_PROFILE, PROJECT_YML_KEYS, resolveCommandDir, resolveFirstMessage, } from "./projectYml.js";
34
36
  import { writeProjectPosture } from "./brief.js";
35
37
  import { carriesNameFlag, harnessFor, hasTranscriptSignal, paneClassifierFor, transcriptDirFor } from "./harnesses.js";
36
38
  import { codexHomeDir, codexInstructionsPhrase, ensureCodexHome, reapCodexHome } from "./codexHome.js";
37
- import { TRIAGE_MESSAGE } from "./kickoff.js";
39
+ import { FIRST_MESSAGE_SHA_ENV, firstMessageDigest } from "./firstMessage.js";
38
40
  import { ageSecondsSince, deriveProvenance, describeForHuman, describeLastLogEvent, humanizeAge, lastLogEvent, lastPermissionMode, reportsAgentStateLog, } from "./stateProvenance.js";
39
41
  import { awaitingFirstPromptSql } from "./firstPrompt.js";
40
42
  import { checkoutRoot, createProfile, forkProfile, PROFILE_FILES, ProfileError, profileExists, profileFileNames, profileNames, profileStatus, readProfileFile, referencedPads, referencedPaths, renderProfileFile, resolveProfileFile, REWRITE_THRESHOLD, templateVars, userProfilesDir, } from "./profiles.js";
41
- import { COMMENT_COUNT_SQL, getTodoDetail, listTodoSummaries, OPEN_BLOCKERS_SQL, TODO_STATUSES, } from "./tools/todos.js";
43
+ import { getTodoDetail, listTodoSummaries, OPEN_BLOCKERS_SQL, TODO_STATUSES, } from "./tools/todos.js";
42
44
  import { createPad, getActivePadByName, listActivePads, overwritePadContent, } from "./tools/pads.js";
43
45
  function usage() {
44
46
  console.log(`hive — run a crew of Claude Code and Codex workers from one lead session
@@ -46,11 +48,16 @@ function usage() {
46
48
  Usage:
47
49
  hive --version version, short sha, and dirty marker for this build
48
50
  hive [path] open the project's session with a lead window
49
- hive lead [path] [--no-dashboard]
51
+ hive lead [path] [--no-dashboard] [--detach]
50
52
  same; lead is the default command. --no-dashboard
51
53
  skips this run's dashboard auto-open -
52
54
  scripts/restart-lead.sh passes it; a human rarely
53
- needs to
55
+ needs it. --detach starts or adopts the lead and
56
+ prints its pane and attach command without attaching
57
+ hive queen [--no-dashboard]
58
+ start, or reattach to, the lead that reads across
59
+ every registered project; its home is
60
+ <data dir>/queen, created on first run
54
61
  hive init [path] [--profile <name>|--no-profile]
55
62
  set the project up: hive.yml, profile, starter pads
56
63
  hive attach [path] attach without adding windows
@@ -59,6 +66,14 @@ Usage:
59
66
  hive show <process> [path] move a running process's pane beside the lead
60
67
  hive hide <process> [path] move it back into the <project>/processes window
61
68
  hive status overview of agents, todos, and wake-ups everywhere
69
+ hive portfolio [--json] one row per registered project: lane, lead, workers,
70
+ todos, needs-human count, wakes; read-only
71
+ hive queen-audit confirmed queen writes, newest first; 30-day history
72
+ [--project-id <id>] filter the target project (queen only across projects)
73
+ [--limit <n>] [--json] default 20, maximum 100; JSON prints one object
74
+ hive next [--print] attach to the lead of the project that needs you most
75
+ (waiting on you, then stuck), starting a dead or
76
+ missing lead first; --print only names the choice
62
77
  hive upgrade [--check] upgrade a global npm install; preview with --check
63
78
  hive upgrade --run run the printed pull/install/build/setup checkout recipe
64
79
  hive setup [--dir <dir>] write a \`hive\` that runs the interpreter this build
@@ -124,6 +139,27 @@ function resolveProject(path, onNotice) {
124
139
  const pinnedProject = getProject(pinned);
125
140
  throw new Error(`This session is locked to project ${pinned} ("${pinnedProject.name}") but "${path}" resolves to ${target ? `project ${target.id} ("${target.name}")` : "no registered project"}. An explicit path argument cannot escape HIVE_PROJECT_LOCK=1. Unset HIVE_AGENT_ID and HIVE_PROJECT_LOCK in this pane, or open a new one, to act on a different project.`);
126
141
  }
142
+ function resolveProjectForWrite(verb, path, onNotice) {
143
+ assertQueenCrossProjectWrite(verb, findProjectForDir(path ?? process.cwd())?.id ?? null, {});
144
+ return resolveProject(path, onNotice);
145
+ }
146
+ function resolveProjectForRead(verb, path) {
147
+ if (path) {
148
+ process.chdir(path);
149
+ const pinned = agentProjectPin();
150
+ if (pinned != null) {
151
+ const target = findProjectForCwd();
152
+ if (target == null || target.id !== pinned) {
153
+ throw new Error(`This session is locked to project ${pinned} ("${getProject(pinned).name}") but "${path}" resolves to ${target ? `project ${target.id} ("${target.name}")` : "no registered project"}. An explicit path argument cannot escape HIVE_PROJECT_LOCK=1. Unset HIVE_AGENT_ID and HIVE_PROJECT_LOCK in this pane, or open a new one, to act on a different project.`);
154
+ }
155
+ }
156
+ }
157
+ const project = pinnedOrCwdProject();
158
+ if (project)
159
+ return project;
160
+ console.error(`hive ${verb}: this directory is not a registered hive project. cd to a registered checkout, or run \`hive init\` here to register it.`);
161
+ process.exit(1);
162
+ }
127
163
  function pinnedOrCwdProject() {
128
164
  const pinned = agentProjectPin();
129
165
  if (pinned != null)
@@ -139,12 +175,9 @@ async function confirmYesNo(question) {
139
175
  return /^y(es)?$/i.test(answer.trim());
140
176
  }
141
177
  async function ensureTrusted(projectId, name, command, dir, env) {
142
- const hash = configHash(name, command, dir, env);
143
- const trusted = db
144
- .prepare("SELECT 1 FROM command_trust WHERE project_id = ? AND name = ? AND config_hash = ?")
145
- .get(projectId, name, hash);
146
- if (trusted)
178
+ if (isTrusted(projectId, name, command, dir, env))
147
179
  return true;
180
+ const hash = configHash(name, command, dir, env);
148
181
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
149
182
  console.log(`! "${name}" is not trusted yet; run hive interactively to review it.`);
150
183
  return false;
@@ -162,6 +195,12 @@ async function ensureTrusted(projectId, name, command, dir, env) {
162
195
  db.prepare("INSERT OR IGNORE INTO command_trust (project_id, name, config_hash) VALUES (?, ?, ?)").run(projectId, name, hash);
163
196
  return true;
164
197
  }
198
+ function isTrusted(projectId, name, command, dir, env) {
199
+ const hash = configHash(name, command, dir, env);
200
+ return !!db
201
+ .prepare("SELECT 1 FROM command_trust WHERE project_id = ? AND name = ? AND config_hash = ?")
202
+ .get(projectId, name, hash);
203
+ }
165
204
  function startYmlCommand(project, name, proc, config) {
166
205
  if (isReservedAgentName(name)) {
167
206
  return `skipped: "lead" is reserved for this project's lead session and cannot be used as a process name`;
@@ -311,24 +350,46 @@ function ensureLeadRow(project, command) {
311
350
  casExpected: "",
312
351
  };
313
352
  }
353
+ function reportMigrationResult(result) {
354
+ if (result.notice)
355
+ console.error(result.notice);
356
+ for (const warning of result.warnings)
357
+ console.error(`! ${warning}`);
358
+ }
314
359
  async function cmdLead(argv) {
315
- const unknownFlag = argv.find((a) => a.startsWith("--") && a !== "--no-dashboard");
360
+ const unknownFlag = argv.find((a) => a.startsWith("--") && a !== "--no-dashboard" && a !== "--detach");
316
361
  if (unknownFlag !== undefined) {
317
- console.error(`hive lead: unknown flag "${unknownFlag}". The only flag is --no-dashboard.`);
362
+ console.error(`hive lead: unknown flag "${unknownFlag}". Flags are --no-dashboard and --detach.`);
318
363
  process.exit(1);
319
364
  }
320
365
  const noDashboard = argv.includes("--no-dashboard");
366
+ const detach = argv.includes("--detach");
321
367
  const path = argv.find((a) => !a.startsWith("--"));
322
368
  let registrationNotice = null;
323
369
  try {
324
- const project = resolveProject(path, (text) => {
370
+ const project = resolveProjectForWrite("hive lead", path, (text) => {
325
371
  registrationNotice = text;
326
372
  });
327
- const session = sessionName();
328
- const hooksPath = ensureHooksFile();
329
- const { config, warnings } = loadProjectYml(project.path);
373
+ const auditIdentity = captureQueenWriteIdentity(project.id);
374
+ const { config, warnings, sources } = loadProjectYml(project.path);
375
+ if (detach && (!process.stdin.isTTY || !process.stdout.isTTY)) {
376
+ let untrusted;
377
+ if (config?.lead && !isTrusted(project.id, "lead", config.lead, null, {}))
378
+ untrusted = "lead";
379
+ if (!untrusted && config) {
380
+ untrusted = Object.entries(config.processes).find(([name, proc]) => proc.auto_start !== false && !isTrusted(project.id, name, proc.command, proc.dir, proc.env))?.[0];
381
+ }
382
+ if (untrusted) {
383
+ console.error(`hive lead: "${untrusted}" is not trusted; run hive lead <path> interactively once`);
384
+ process.exitCode = 1;
385
+ return;
386
+ }
387
+ }
388
+ reportMigrationResult(migrateLegacyConfig());
330
389
  for (const w of warnings)
331
390
  console.log(`! ${w}`);
391
+ const session = sessionName();
392
+ const hooksPath = ensureHooksFile();
332
393
  let leadCommand = "claude";
333
394
  if (!config) {
334
395
  console.log("No hive.yml here; starting a plain claude lead. Add a hive.yml to define project commands and a custom lead (run hive --help for the format).");
@@ -342,6 +403,10 @@ async function cmdLead(argv) {
342
403
  }
343
404
  }
344
405
  const leadHarness = harnessFor(leadCommand);
406
+ let firstMessage = resolveFirstMessage(config, sources).message;
407
+ if (firstMessage !== "" && !(await (await import("./kickoff.js")).kickoffGate(project.path)).ok)
408
+ firstMessage = "";
409
+ let promptSuffix = "";
345
410
  // Posture is rendered once, then delivered through whichever channel this harness has: a
346
411
  // claude lead gets it as --append-system-prompt-file below; a codex lead gets the identical
347
412
  // text as config.toml's developer_instructions, built after ensureLeadRow (it needs leadActorId).
@@ -379,6 +444,9 @@ async function cmdLead(argv) {
379
444
  leadCommand += ` ${leadHarness.briefDelivery.systemPromptArgs(posturePath).map(shellQuote).join(" ")}`;
380
445
  console.log(`- profile: ${profile} (${postureSource} posture; see it with: hive posture)`);
381
446
  }
447
+ if (leadHarness.initialPromptArgs && firstMessage !== "") {
448
+ promptSuffix = ` ${leadHarness.initialPromptArgs(firstMessage).map(shellQuote).join(" ")}`;
449
+ }
382
450
  }
383
451
  const windowName = project.name;
384
452
  const { agentId: leadAgentId, actorId: leadActorId, previousTarget, previousSocket, previousPanePid, previousCommand, previousCodexHome, casExpected, } = ensureLeadRow(project, leadCommand);
@@ -387,6 +455,14 @@ async function cmdLead(argv) {
387
455
  // actually starts under it) and correct regardless of whether this invocation ends up creating
388
456
  // a pane or adopting one. Which home ends up orphaned - this one, or the previous invocation's -
389
457
  // is decided after createdPane is known, below.
458
+ const leadEnv = {
459
+ HIVE_AGENT_ID: leadActorId,
460
+ HIVE_AGENT_NAME: LEAD_NAME,
461
+ HIVE_LEAD: "1",
462
+ HIVE_DATA_DIR: dataDir,
463
+ HIVE_PROJECT_LOCK: "",
464
+ HIVE_PROJECT_PATH: "",
465
+ };
390
466
  let newCodexHomeKey;
391
467
  if (leadHarness.needsHome) {
392
468
  newCodexHomeKey = randomUUID();
@@ -401,6 +477,7 @@ async function cmdLead(argv) {
401
477
  const home = ensureCodexHome({
402
478
  key: newCodexHomeKey,
403
479
  actorId: leadActorId,
480
+ serverEnv: leadEnv,
404
481
  cwd: project.path,
405
482
  brief: renderedPosture ?? "",
406
483
  lead: true,
@@ -414,8 +491,8 @@ async function cmdLead(argv) {
414
491
  throw e;
415
492
  }
416
493
  leadCommand += ` ${homeArgs.map(shellQuote).join(" ")}`;
417
- if (leadHarness.initialPromptArgs) {
418
- leadCommand += ` ${leadHarness.initialPromptArgs(TRIAGE_MESSAGE).map(shellQuote).join(" ")}`;
494
+ if (leadHarness.initialPromptArgs && firstMessage !== "") {
495
+ promptSuffix = ` ${leadHarness.initialPromptArgs(firstMessage).map(shellQuote).join(" ")}`;
419
496
  }
420
497
  if (renderedPosture !== null) {
421
498
  console.log(`- profile: ${profile} (${postureSource} posture; see it with: hive posture)`);
@@ -425,19 +502,16 @@ async function cmdLead(argv) {
425
502
  (instructionsPhrase ? ` (${instructionsPhrase})` : ""));
426
503
  }
427
504
  const envFlags = buildEnvFlags({
428
- HIVE_AGENT_ID: leadActorId,
429
- HIVE_AGENT_NAME: LEAD_NAME,
430
- HIVE_LEAD: "1",
431
- HIVE_DATA_DIR: dataDir,
432
- HIVE_PROJECT_LOCK: "",
433
- HIVE_PROJECT_PATH: "",
505
+ ...leadEnv,
506
+ [FIRST_MESSAGE_SHA_ENV]: promptSuffix !== "" ? firstMessageDigest(firstMessage) : "",
434
507
  ...(newCodexHomeKey ? { CODEX_HOME: codexHomeDir(newCodexHomeKey) } : {}),
435
508
  });
509
+ const launchCommand = leadCommand + promptSuffix;
436
510
  const { leadPane, leadWindow, createdPane } = withWindowClaim(() => {
437
511
  let leadPane;
438
512
  let leadWindow;
439
513
  let createdPane;
440
- const started = ensureSession(session, project.path, { envFlags, command: leadCommand });
514
+ const started = ensureSession(session, project.path, { envFlags, command: launchCommand });
441
515
  if (started.created) {
442
516
  const claimed = claimInitialWindow(started, windowName, project.id);
443
517
  leadPane = claimed.pane;
@@ -456,13 +530,13 @@ async function cmdLead(argv) {
456
530
  createdPane = false;
457
531
  }
458
532
  else if (!foundWindow) {
459
- const fresh = createWindow(session, windowName, project.path, envFlags, leadCommand, project.id);
533
+ const fresh = createWindow(session, windowName, project.path, envFlags, launchCommand, project.id, detach);
460
534
  leadPane = fresh.pane;
461
535
  leadWindow = fresh.window;
462
536
  createdPane = true;
463
537
  }
464
538
  else {
465
- leadPane = tmux("split-window", "-P", "-F", "#{pane_id}", "-t", foundWindow, "-c", project.path, ...envFlags, leadCommand);
539
+ leadPane = tmux("split-window", ...(detach ? ["-d"] : []), "-P", "-F", "#{pane_id}", "-t", foundWindow, "-c", project.path, ...envFlags, launchCommand);
466
540
  leadWindow = foundWindow;
467
541
  createdPane = true;
468
542
  }
@@ -484,18 +558,7 @@ async function cmdLead(argv) {
484
558
  // also reaps a stale codex home left over from a lead config that has since switched away from
485
559
  // codex, since createdPane's branch does not require leadHarness.needsHome to fire.
486
560
  const recordedCodexHome = createdPane ? (newCodexHomeKey ?? "") : previousCodexHome;
487
- const wonRace = db.transaction(() => {
488
- const updated = db
489
- .prepare("UPDATE agents SET tmux_target = ?, tmux_socket = ?, pane_pid = ?, command = ?, codex_home = ? WHERE id = ? AND tmux_target = ? AND status = 'running'")
490
- .run(leadPane, tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR), panePid(leadPane), recordedCommand, recordedCodexHome, leadAgentId, casExpected).changes;
491
- if (updated === 0)
492
- return false;
493
- db.prepare(`UPDATE wakes SET deliver_pane = ?, held_at = NULL, held_reason = NULL
494
- WHERE ${ACTIVE_TIMER_WHERE} AND deliver_actor = ?
495
- AND (? = 1 OR held_reason IS NULL OR held_reason NOT LIKE ?)`).run(leadPane, leadActorId, createdPane ? 1 : 0, `${HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX}%`);
496
- return true;
497
- })();
498
- if (!wonRace) {
561
+ const abandonClaim = () => {
499
562
  if (createdPane) {
500
563
  try {
501
564
  waitForPaneEstablished(leadPane);
@@ -506,6 +569,31 @@ async function cmdLead(argv) {
506
569
  }
507
570
  if (newCodexHomeKey)
508
571
  reapCodexHome(newCodexHomeKey);
572
+ };
573
+ let wonRace;
574
+ try {
575
+ wonRace = db.transaction(() => {
576
+ const updated = db
577
+ .prepare("UPDATE agents SET tmux_target = ?, tmux_socket = ?, pane_pid = ?, command = ?, codex_home = ? WHERE id = ? AND tmux_target = ? AND status = 'running'")
578
+ .run(leadPane, tmuxSocketPath(process.env.TMUX, process.env.TMUX_TMPDIR), panePid(leadPane), recordedCommand, recordedCodexHome, leadAgentId, casExpected).changes;
579
+ if (updated === 0)
580
+ return false;
581
+ db.prepare(`UPDATE wakes SET deliver_pane = ?, held_at = NULL, held_reason = NULL
582
+ WHERE ${ACTIVE_TIMER_WHERE} AND deliver_actor = ?
583
+ AND (? = 1 OR held_reason IS NULL OR held_reason NOT LIKE ?)`).run(leadPane, leadActorId, createdPane ? 1 : 0, `${HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX}%`);
584
+ confirmQueenWrite("hive lead", project.id, { detach }, {
585
+ agent_id: leadAgentId,
586
+ disposition: createdPane ? "started" : "adopted",
587
+ }, auditIdentity);
588
+ return true;
589
+ })();
590
+ }
591
+ catch (e) {
592
+ abandonClaim();
593
+ throw e;
594
+ }
595
+ if (!wonRace) {
596
+ abandonClaim();
509
597
  throw new Error("Another `hive lead` won the race to record a live pane for this project's lead session (both saw the " +
510
598
  "same dead pane and both tried to replace it, or this row was closed by another process mid-restart). " +
511
599
  "Re-run `hive lead`; it will attach to the pane that invocation recorded.");
@@ -539,6 +627,11 @@ async function cmdLead(argv) {
539
627
  console.error(registrationNotice);
540
628
  registrationNotice = null;
541
629
  }
630
+ if (detach) {
631
+ console.log(`LEAD_PANE=${leadPane}`);
632
+ console.log(`ATTACH_COMMAND=hive attach ${shellQuote(project.path)}`);
633
+ return;
634
+ }
542
635
  if (!noDashboard)
543
636
  maybeOpenDashboard(project, !!config?.dashboard);
544
637
  attach(session, project, leadWindow);
@@ -549,11 +642,65 @@ async function cmdLead(argv) {
549
642
  throw e;
550
643
  }
551
644
  }
645
+ function publishQueenYml(home) {
646
+ const yml = join(home, "hive.yml");
647
+ const staged = join(home, `.hive.yml.${process.pid}.${randomUUID()}`);
648
+ writeFileSync(staged, `profile: ${QUEEN_PROFILE}\n`, { flag: "wx" });
649
+ try {
650
+ linkSync(staged, yml);
651
+ }
652
+ catch (e) {
653
+ if (e.code !== "EEXIST")
654
+ throw e;
655
+ }
656
+ finally {
657
+ unlinkSync(staged);
658
+ }
659
+ if (lstatSync(yml).isSymbolicLink()) {
660
+ throw new Error(`hive queen: ${yml} is a symlink. The queen's config must be a real file; remove the link and run hive queen again.`);
661
+ }
662
+ const { config } = loadProjectYml(home);
663
+ if (activeProfile(config) !== QUEEN_PROFILE) {
664
+ throw new Error(`hive queen: ${yml} already exists and does not select "profile: ${QUEEN_PROFILE}". hive will not ` +
665
+ "overwrite it. Add that line yourself, or move the file away and run hive queen again.");
666
+ }
667
+ }
668
+ async function cmdQueen(argv) {
669
+ const parsed = parseArgs(argv, { flags: ["--no-dashboard"] });
670
+ rejectUnknownFlags("queen", parsed, "--no-dashboard");
671
+ if (parsed.positional.length > 0) {
672
+ console.error(`hive queen: takes no path. The queen lives in the data dir (${queenHomeDir()}).`);
673
+ process.exit(1);
674
+ }
675
+ if (process.env.HIVE_PROJECT_LOCK === "1" || agentProjectPin() != null) {
676
+ throw new Error("hive queen: this session is locked to its own project (HIVE_PROJECT_LOCK=1), so it cannot create or " +
677
+ "start the queen. Run hive queen from your own terminal.");
678
+ }
679
+ const home = queenHomeDir();
680
+ let existing = null;
681
+ try {
682
+ existing = lstatSync(home);
683
+ }
684
+ catch {
685
+ existing = null;
686
+ }
687
+ if (existing?.isSymbolicLink() || (existing && !existing.isDirectory())) {
688
+ throw new Error(`hive queen: ${home} exists and is not a plain directory. The queen's home must be a real directory ` +
689
+ "inside the data dir; move it away and run hive queen again.");
690
+ }
691
+ mkdirSync(home, { recursive: true });
692
+ publishQueenYml(home);
693
+ const queen = addProject(home, "queen");
694
+ generateQueenDashboardNow();
695
+ if (!parsed.flags.has("--no-dashboard"))
696
+ maybeOpenQueenDashboard(queen);
697
+ await cmdLead([home, ...parsed.flags]);
698
+ }
552
699
  const HIVE_YML_TEMPLATE = `# hive project config. Read by \`hive lead\` from the project root.
553
700
  # Commands defined here run only after a one-time interactive approval,
554
701
  # and re-require it whenever they change.
555
702
 
556
- placement: split # placement for workers and visible processes: split (panes) or
703
+ # placement: split # placement for workers and visible processes: split (panes) or
557
704
  # window (tabs)
558
705
 
559
706
  # layout: main-vertical # pane arrangement for placement: split.
@@ -566,6 +713,7 @@ placement: split # placement for workers and visible processes: s
566
713
  # agents: [claude, codex] # crew harness pool; the first entry is the default
567
714
 
568
715
  # lead_branches: [main, master] # branches where a session gets hive's kickoff
716
+ # first_message: "..." # a lead's first turn; absent or "" sends none
569
717
 
570
718
  # context_checkpoint_percent: null # unset means off; integer 1-100 to enable
571
719
 
@@ -573,11 +721,6 @@ placement: split # placement for workers and visible processes: s
573
721
 
574
722
  # dashboard: true # write .hive/dashboard.html (default: false)
575
723
 
576
- # review_tags: [from-review] # todo tags \`hive doctor\` counts as review findings and
577
- # reports as triaged (has a comment, completed, or archived)
578
- # or untriaged. A tag also matches its own suffixed rounds.
579
- # Absent means it tracks none.
580
-
581
724
  # vars: # substituted into the profile runbook
582
725
  # repo: owner/name # {{repo}}
583
726
  # ticket_prefix: DEVX # sections needing it drop when it is unset
@@ -729,6 +872,7 @@ async function cmdInit(argv) {
729
872
  rejectUnknownFlags("init", parsed, "--profile <name> and --no-profile");
730
873
  requireFlagValues("init", parsed);
731
874
  const noProfile = parsed.flags.has("--no-profile");
875
+ const profileFlag = noProfile || parsed.values.has("--profile");
732
876
  const path = parsed.positional[0];
733
877
  let chosen = noProfile ? NO_PROFILE : null;
734
878
  if (parsed.values.has("--profile")) {
@@ -771,6 +915,7 @@ async function cmdInit(argv) {
771
915
  process.exit(1);
772
916
  }
773
917
  const existing = getProjectByPath(registration);
918
+ assertQueenCrossProjectWrite("hive init", existing?.id ?? null, {});
774
919
  project = existing ?? addProject(registration);
775
920
  newlyRegistered = existing == null;
776
921
  }
@@ -789,21 +934,32 @@ async function cmdInit(argv) {
789
934
  if (path == null && newlyRegistered)
790
935
  console.error(registrationNoticeText(project));
791
936
  const ymlPath = join(project.path, "hive.yml");
792
- const already = existsSync(ymlPath) ? loadProjectYml(project.path).config?.profile ?? null : null;
937
+ const ymlExists = existsSync(ymlPath);
938
+ const loaded = loadProjectYml(project.path);
939
+ const projectProfileSet = ymlExists && loaded.sources.profile === "project";
940
+ const already = projectProfileSet ? loaded.config?.profile ?? null : null;
941
+ let inheritedProfile = false;
793
942
  if (already != null) {
794
943
  if (chosen != null && chosen !== already) {
795
944
  console.log(`- hive.yml: already set to "profile: ${already}"; edit it by hand to change it`);
796
945
  }
797
946
  chosen = already;
798
947
  }
948
+ else if (projectProfileSet && !profileFlag) {
949
+ chosen = null;
950
+ }
951
+ else if (!projectProfileSet && !profileFlag && loaded.config?.profile != null) {
952
+ chosen = loaded.config.profile;
953
+ inheritedProfile = loaded.sources.profile === "global";
954
+ }
799
955
  else if (chosen == null && process.stdin.isTTY && process.stdout.isTTY) {
800
956
  chosen = await askForProfile();
801
957
  }
802
- if (!existsSync(ymlPath)) {
803
- writeFileSync(ymlPath, HIVE_YML_TEMPLATE + (chosen ? `\nprofile: ${chosen}\n` : ""));
958
+ if (!ymlExists) {
959
+ writeFileSync(ymlPath, HIVE_YML_TEMPLATE + (chosen && !inheritedProfile ? `\nprofile: ${chosen}\n` : ""));
804
960
  console.log(`- hive.yml: created${chosen ? ` with profile: ${chosen}` : ""}`);
805
961
  }
806
- else if (already == null && chosen != null) {
962
+ else if (already == null && chosen != null && !inheritedProfile) {
807
963
  writeProfileKey(ymlPath, chosen);
808
964
  console.log(`- hive.yml: added profile: ${chosen}`);
809
965
  }
@@ -876,7 +1032,7 @@ itself to this project before any real work runs.`);
876
1032
  }
877
1033
  const NO_RUNBOOK_PAD_MESSAGE = `is on "profile: none" but has no runbook pad. Create one with: hive init`;
878
1034
  function cmdRunbook(path) {
879
- const project = resolveProject(path);
1035
+ const project = resolveProjectForRead("runbook", path);
880
1036
  const { config, warnings } = loadProjectYml(project.path);
881
1037
  for (const w of warnings)
882
1038
  console.log(`! ${w}`);
@@ -901,7 +1057,7 @@ function cmdRunbook(path) {
901
1057
  process.stdout.write(withTrailingNewline(rendered));
902
1058
  }
903
1059
  function cmdPosture(path) {
904
- const project = resolveProject(path);
1060
+ const project = resolveProjectForRead("posture", path);
905
1061
  const { config, warnings } = loadProjectYml(project.path);
906
1062
  for (const w of warnings)
907
1063
  console.log(`! ${w}`);
@@ -1042,7 +1198,7 @@ function cmdProfile(argv) {
1042
1198
  vars = mergedProjectVars(here ? loadProjectYml(here.path).config : null);
1043
1199
  }
1044
1200
  else {
1045
- const project = resolveProject();
1201
+ const project = resolveProjectForRead("profile read");
1046
1202
  const { config, warnings } = loadProjectYml(project.path);
1047
1203
  for (const w of warnings)
1048
1204
  console.log(`! ${w}`);
@@ -1089,6 +1245,22 @@ function maybeOpenDashboard(project, dashboardEnabled) {
1089
1245
  return;
1090
1246
  if (!dashboardFileContained(dashboardFile, project.path))
1091
1247
  return;
1248
+ openDashboardOnce(project, dashboardFile);
1249
+ }
1250
+ function maybeOpenQueenDashboard(project) {
1251
+ if (process.platform !== "darwin")
1252
+ return;
1253
+ const dashboardFile = join(project.path, "dashboard.html");
1254
+ try {
1255
+ if (!existsSync(dashboardFile) || !dashboardFileContained(dashboardFile, project.path))
1256
+ return;
1257
+ }
1258
+ catch {
1259
+ return;
1260
+ }
1261
+ openDashboardOnce(project, dashboardFile);
1262
+ }
1263
+ function openDashboardOnce(project, dashboardFile) {
1092
1264
  const actor = currentActor();
1093
1265
  const claim = db
1094
1266
  .prepare(`INSERT INTO kv (project_id, key, value, updated_by, expires_at)
@@ -1114,7 +1286,7 @@ function cmdAttach(argv) {
1114
1286
  process.exit(1);
1115
1287
  }
1116
1288
  const path = argv.find((a) => !a.startsWith("--"));
1117
- const project = resolveProject(path);
1289
+ const project = resolveProjectForWrite("hive attach", path);
1118
1290
  maybeOpenDashboard(project, !!loadProjectYml(project.path).config?.dashboard);
1119
1291
  const session = sessionName();
1120
1292
  const window = withWindowClaim(() => {
@@ -1142,7 +1314,7 @@ async function cmdStart(argv) {
1142
1314
  console.log("Usage: hive start <process> [path]");
1143
1315
  process.exit(1);
1144
1316
  }
1145
- const project = resolveProject(path);
1317
+ const project = resolveProjectForWrite("hive start", path);
1146
1318
  const { config, warnings } = loadProjectYml(project.path);
1147
1319
  for (const w of warnings)
1148
1320
  console.log(`! ${w}`);
@@ -1170,7 +1342,7 @@ function resolveNamedProcess(argv, verb) {
1170
1342
  console.log(`Usage: hive ${verb} <process> [path]`);
1171
1343
  process.exit(1);
1172
1344
  }
1173
- const project = resolveProject(parsed.positional[1]);
1345
+ const project = resolveProjectForWrite(`hive ${verb}`, parsed.positional[1]);
1174
1346
  const { config, warnings } = loadProjectYml(project.path);
1175
1347
  for (const w of warnings)
1176
1348
  console.log(`! ${w}`);
@@ -1210,7 +1382,7 @@ function leadPaneExitedHookCommand() {
1210
1382
  // started from inside a worker's pane hands them to every run-shell it ever runs, where a stale
1211
1383
  // HIVE_AGENT_ID is a hard refusal before the verb is reached at all. The same clearing
1212
1384
  // scripts/restart-lead.sh does around its own `hive lead`, plus the two project vars.
1213
- const clear = ["HIVE_AGENT_ID", "HIVE_AGENT_NAME", "HIVE_LEAD", "HIVE_PROJECT_LOCK", "HIVE_PROJECT_PATH"]
1385
+ const clear = ["HIVE_AGENT_ID", "HIVE_AGENT_NAME", "HIVE_LEAD", FIRST_MESSAGE_SHA_ENV, "HIVE_PROJECT_LOCK", "HIVE_PROJECT_PATH"]
1214
1386
  .flatMap((name) => ["-u", name]);
1215
1387
  // Redirected because tmux prints a run-shell's output into the pane it fires from, which is a
1216
1388
  // human's own window: a backstop that failed to start must not type into it.
@@ -1292,7 +1464,7 @@ function cmdStop(argv) {
1292
1464
  const parsed = parseArgs(argv, { flags: ["--all"] });
1293
1465
  rejectUnknownFlags("stop", parsed, "--all");
1294
1466
  if (parsed.flags.has("--all")) {
1295
- const project = resolveProject(parsed.positional[0]);
1467
+ const project = resolveProjectForWrite("hive stop", parsed.positional[0]);
1296
1468
  const stopped = stopAllProcesses(project.id, STOP_REASONS.byHand);
1297
1469
  console.log(stopped.length === 0 ? "No processes are running." : stopped.map(stopLine).join("\n"));
1298
1470
  return;
@@ -1302,7 +1474,7 @@ function cmdStop(argv) {
1302
1474
  console.log("Usage: hive stop <process> [path] | hive stop --all [path]");
1303
1475
  process.exit(1);
1304
1476
  }
1305
- const project = resolveProject(parsed.positional[1]);
1477
+ const project = resolveProjectForWrite("hive stop", parsed.positional[1]);
1306
1478
  const row = runningCommandRow(project.id, name);
1307
1479
  if (!row) {
1308
1480
  const { config, warnings } = loadProjectYml(project.path);
@@ -1462,7 +1634,6 @@ const verboseInfo = (_check, label, ...lines) => {
1462
1634
  if (doctorVerbose)
1463
1635
  report("info", label, lines);
1464
1636
  };
1465
- const isReviewFindingTag = (bases, tag) => bases.some((base) => tag === base || tag.startsWith(`${base}-`));
1466
1637
  function cmdUpgrade(argv) {
1467
1638
  const parsed = parseArgs(argv, { flags: ["--check", "--run"] });
1468
1639
  rejectUnknownFlags("upgrade", parsed, "--check, --run");
@@ -1576,6 +1747,7 @@ function cmdSetup(argv) {
1576
1747
  console.log("Or pin this worktree anyway: hive setup --force");
1577
1748
  process.exit(1);
1578
1749
  }
1750
+ reportMigrationResult(migrateLegacyConfig());
1579
1751
  mkdirSync(dir, { recursive: true });
1580
1752
  writeFileSync(file, dispatcherScript(node, cli));
1581
1753
  chmodSync(file, 0o755);
@@ -1705,7 +1877,7 @@ function reportProjectScope(here) {
1705
1877
  if (locked && project.id !== here?.id)
1706
1878
  continue;
1707
1879
  if (project.path === homePath || project.path === "/") {
1708
- warn("project scope", `"${project.name}" (${project.path}) is a home directory; every unregistered directory under it resolves to this project. hive project prune removes it once it holds nothing.`);
1880
+ warn("project scope", `"${project.name}" (${project.path}) is a home directory; every unregistered directory under it resolves to this project. The project_prune tool with this project's id (from any hive session) removes it once it holds nothing.`);
1709
1881
  }
1710
1882
  }
1711
1883
  for (const parent of projects) {
@@ -2023,6 +2195,33 @@ function reportStalledWorkers(projectId) {
2023
2195
  "worker does not reliably remember what it was doing (.claude/rules/worker-state.md).");
2024
2196
  }
2025
2197
  }
2198
+ function reportEffectiveConfig(loaded) {
2199
+ const config = loaded.config ?? BUILT_IN_PROJECT_YML;
2200
+ for (const key of PROJECT_YML_KEYS) {
2201
+ info(`config ${key}`, `${JSON.stringify(config[key])} (source: ${loaded.sources[key] ?? "built-in"})`);
2202
+ }
2203
+ const varKeys = new Set(Object.keys(config.vars));
2204
+ for (const key of Object.keys(loaded.sources)) {
2205
+ if (key.startsWith("vars."))
2206
+ varKeys.add(key.slice("vars.".length));
2207
+ }
2208
+ for (const key of [...varKeys].sort()) {
2209
+ const value = Object.hasOwn(config.vars, key) ? config.vars[key] : null;
2210
+ info(`config vars.${key}`, `${JSON.stringify(value)} (source: ${loaded.sources[`vars.${key}`] ?? "built-in"})`);
2211
+ }
2212
+ const { mode: attachMode, source: attachSource } = loaded.attach;
2213
+ info("attach mode", attachSource === "env"
2214
+ ? `${attachMode} (HIVE_ATTACH_MODE override; testing only, does not reach auto-attach; source: env)`
2215
+ : attachSource === "global"
2216
+ ? `${attachMode} (set with \`hive setup --attach\`; source: global)`
2217
+ : `${attachMode} (default; set with \`hive setup --attach\`; source: built-in)`);
2218
+ const { value: autoAttach, source: autoAttachSource } = loaded.autoAttach;
2219
+ info("auto-attach", autoAttachSource === "env"
2220
+ ? `${autoAttach} (HIVE_AUTO_ATTACH override; testing only; source: env)`
2221
+ : autoAttachSource === "global"
2222
+ ? `${autoAttach} (set with \`hive setup --auto-attach\`; source: global)`
2223
+ : `${autoAttach} (default; set with \`hive setup --auto-attach\`; source: built-in)`);
2224
+ }
2026
2225
  function cmdDoctor(argv) {
2027
2226
  const strict = argv.includes("--strict");
2028
2227
  doctorVerbose = argv.includes("--verbose");
@@ -2032,6 +2231,7 @@ function cmdDoctor(argv) {
2032
2231
  console.error(`hive doctor: unknown argument "${unknown}". Flags are --strict and --verbose.`);
2033
2232
  process.exit(1);
2034
2233
  }
2234
+ reportMigrationResult(migrateLegacyConfig());
2035
2235
  let failures = 0;
2036
2236
  const fail = (label, ...lines) => {
2037
2237
  failures += 1;
@@ -2089,9 +2289,15 @@ function cmdDoctor(argv) {
2089
2289
  reportSessionInterpreters();
2090
2290
  reportCodexHomes(here?.id ?? null);
2091
2291
  const loaded = loadProjectYml(here?.path ?? process.cwd());
2092
- for (const w of loaded.warnings)
2093
- warn("hive.yml", w);
2292
+ for (const w of loaded.warnings) {
2293
+ const qualified = /^([^:]+): ([\s\S]*)$/.exec(w);
2294
+ if (qualified)
2295
+ warn(qualified[1], qualified[2]);
2296
+ else
2297
+ warn("hive.yml", w);
2298
+ }
2094
2299
  const config = loaded.config;
2300
+ reportEffectiveConfig(loaded);
2095
2301
  const profile = activeProfile(config);
2096
2302
  if (here && existsSync(join(here.path, ".claude", "dashboard", "index.html"))) {
2097
2303
  info("dashboard", "an older hive wrote .claude/dashboard/index.html; hive now writes .hive/dashboard.html. Delete the old file and directory.");
@@ -2399,20 +2605,7 @@ function cmdDoctor(argv) {
2399
2605
  return health.message;
2400
2606
  });
2401
2607
  {
2402
- const { value, source } = resolvedAutoAttach();
2403
- info("auto-attach", source === "env"
2404
- ? `${value} (HIVE_AUTO_ATTACH override; testing only)`
2405
- : source === "config"
2406
- ? `${value} (set with \`hive setup --auto-attach\`)`
2407
- : `${value} (default; set with \`hive setup --auto-attach\`)`);
2408
- }
2409
- {
2410
- const { mode, source } = resolvedAttachMode();
2411
- info("attach mode", source === "env"
2412
- ? `${mode} (HIVE_ATTACH_MODE override; testing only, does not reach auto-attach)`
2413
- : source === "config"
2414
- ? `${mode} (set with \`hive setup --attach\`)`
2415
- : `${mode} (default; set with \`hive setup --attach\`)`);
2608
+ const { mode } = loaded.attach;
2416
2609
  if (mode === "raw") {
2417
2610
  try {
2418
2611
  const owned = tmux("list-windows", "-a", "-F", "#{session_name}:#{window_id}\t#{@hive-owned}")
@@ -2432,8 +2625,7 @@ function cmdDoctor(argv) {
2432
2625
  `monitor-bell ${option("-w", "monitor-bell")}`);
2433
2626
  }
2434
2627
  }
2435
- catch {
2436
- }
2628
+ catch { }
2437
2629
  }
2438
2630
  }
2439
2631
  if (here) {
@@ -2444,25 +2636,6 @@ function cmdDoctor(argv) {
2444
2636
  info("processes", `${running} running (${located}), ${notStarted} defined not running`);
2445
2637
  }
2446
2638
  }
2447
- const reviewTags = config?.review_tags ?? [];
2448
- // Never name hive.yml here: doctor stays silent about that file when nothing is wrong with it
2449
- // (test/config-warnings.test.mjs), and this line prints on every ordinary run.
2450
- if (here && reviewTags.length === 0) {
2451
- info("review findings", "no review_tags configured; nothing to track.");
2452
- }
2453
- else if (here) {
2454
- const findings = db
2455
- .prepare(`SELECT id, tags, status, archived_at, ${COMMENT_COUNT_SQL} AS comment_count
2456
- FROM todos t WHERE t.project_id = ?`)
2457
- .all(here.id).filter((t) => parseTags(t.tags).some((tag) => isReviewFindingTag(reviewTags, tag)));
2458
- const untriaged = findings.filter((t) => t.comment_count === 0 && t.status !== "completed" && t.archived_at == null);
2459
- info("review findings", `${findings.length} tracked (tagged ${reviewTags.join(", ")}): ` +
2460
- `${findings.length - untriaged.length} triaged, ${untriaged.length} untriaged.`);
2461
- if (untriaged.length > 0) {
2462
- warn("review findings", `${untriaged.length} untriaged: ${untriaged.map((t) => `todo ${t.id}`).join(", ")}.`, "Triage means a comment recording a decision, or completed/archived - comment with the outcome " +
2463
- "(dispatched, rejected and why, etc.) on each before closing this wave.");
2464
- }
2465
- }
2466
2639
  const problems = failures + (strict ? gatingWarnings : 0);
2467
2640
  const tail = strict
2468
2641
  ? `${warnings} warning(s), ${gatingWarnings} promoted by --strict.`
@@ -2576,6 +2749,144 @@ function findPadExports(projectId, name) {
2576
2749
  .filter((f) => f.startsWith(prefix) && f.endsWith(".md"))
2577
2750
  .map((f) => join(tmpdir(), f));
2578
2751
  }
2752
+ function canonicalPath(path) {
2753
+ try {
2754
+ return realpathSync(path);
2755
+ }
2756
+ catch {
2757
+ return resolve(path);
2758
+ }
2759
+ }
2760
+ function nextCandidates() {
2761
+ const queenRoot = canonicalPath(join(storeDir(), "queen"));
2762
+ const byCount = (a, b) => b - a;
2763
+ return collectPortfolio()
2764
+ .projects.filter((p) => (p.lane === "waiting_on_you" || p.lane === "stuck" || (p.lane === "moving" && p.needs_human > 0)) &&
2765
+ canonicalPath(p.root) !== queenRoot)
2766
+ .sort((a, b) => {
2767
+ const tier = (p) => (p.lane === "waiting_on_you" ? 0 : p.needs_human > 0 ? 1 : 2);
2768
+ if (tier(a) !== tier(b))
2769
+ return tier(a) - tier(b);
2770
+ if (a.lane === "waiting_on_you") {
2771
+ return (byCount(a.needs_human, b.needs_human) ||
2772
+ a.last_activity_at.localeCompare(b.last_activity_at) ||
2773
+ a.id - b.id);
2774
+ }
2775
+ return (byCount(a.needs_human, b.needs_human) ||
2776
+ byCount(a.todos.blocked_in_progress, b.todos.blocked_in_progress) ||
2777
+ byCount(a.wakes.overdue, b.wakes.overdue) ||
2778
+ byCount(a.workers.needs_input, b.workers.needs_input) ||
2779
+ a.last_activity_at.localeCompare(b.last_activity_at) ||
2780
+ a.id - b.id);
2781
+ });
2782
+ }
2783
+ function collectProjectById(id) {
2784
+ return collectPortfolio().projects.find((p) => p.id === id);
2785
+ }
2786
+ async function cmdNext(argv) {
2787
+ const parsed = parseArgs(argv, { flags: ["--print"] });
2788
+ if (parsed.positional.length > 0)
2789
+ parsed.unknown.push(parsed.positional[0]);
2790
+ rejectUnknownFlags("next", parsed, "--print");
2791
+ if (process.env.HIVE_PROJECT_LOCK === "1") {
2792
+ console.error("hive next: this session is locked to one project (HIVE_PROJECT_LOCK=1) and cannot jump to another. Run it from a pane outside a worker session.");
2793
+ process.exit(1);
2794
+ }
2795
+ const chosen = nextCandidates()[0];
2796
+ if (!chosen) {
2797
+ console.log("No project needs you: none is waiting, stuck, or holding a needs-human item.");
2798
+ return;
2799
+ }
2800
+ console.log(JSON.stringify({
2801
+ project_id: chosen.id,
2802
+ name: chosen.name,
2803
+ root: chosen.root,
2804
+ lane: chosen.lane,
2805
+ reasons: chosen.reasons,
2806
+ lead_state: chosen.lead.state,
2807
+ }));
2808
+ if (parsed.flags.has("--print"))
2809
+ return;
2810
+ assertQueenCrossProjectWrite("hive next", chosen.id, {});
2811
+ if (!chosen.root_exists) {
2812
+ console.error(`hive next: project ${chosen.id} ("${chosen.name}") root is missing: ${chosen.root}. Remove the stale project with the project_prune tool, or restore the directory.`);
2813
+ process.exit(1);
2814
+ }
2815
+ let state = collectProjectById(chosen.id)?.lead.state ?? "none";
2816
+ if (state === "unknown") {
2817
+ console.error(`hive next: cannot tell whether the lead for project ${chosen.id} ("${chosen.name}") is alive (tmux did not answer for its socket). Starting nothing.`);
2818
+ process.exit(1);
2819
+ }
2820
+ await cmdLead([chosen.root, "--detach"]);
2821
+ state = collectProjectById(chosen.id)?.lead.state ?? "none";
2822
+ if (state !== "alive" || (process.exitCode !== undefined && process.exitCode !== 0)) {
2823
+ console.error(`hive next: the lead for project ${chosen.id} ("${chosen.name}") did not start cleanly (${state}). Not attaching.`);
2824
+ process.exit(1);
2825
+ }
2826
+ cmdAttach([chosen.root]);
2827
+ }
2828
+ function cmdQueenAudit(argv) {
2829
+ const parsed = parseArgs(argv, { flags: ["--json"], valued: ["--project-id", "--limit"] });
2830
+ if (parsed.positional.length > 0)
2831
+ parsed.unknown.push(parsed.positional[0]);
2832
+ rejectUnknownFlags("queen-audit", parsed, "--project-id <id>, --limit <n>, --json");
2833
+ requireFlagValues("queen-audit", parsed);
2834
+ let report;
2835
+ try {
2836
+ const project = pinnedOrCwdProject();
2837
+ if (!project)
2838
+ throw new Error("hive queen-audit: the cwd is not a registered project. Run this command from a registered project.");
2839
+ report = listQueenAudit({
2840
+ project_id: parsed.values.has("--project-id") ? Number(parsed.values.get("--project-id")) : undefined,
2841
+ limit: parsed.values.has("--limit") ? Number(parsed.values.get("--limit")) : undefined,
2842
+ }, project.id);
2843
+ }
2844
+ catch (e) {
2845
+ console.error(errorMessage(e));
2846
+ process.exitCode = 1;
2847
+ return;
2848
+ }
2849
+ if (parsed.flags.has("--json")) {
2850
+ console.log(JSON.stringify(report));
2851
+ return;
2852
+ }
2853
+ if (report.entries.length === 0) {
2854
+ console.log("No queen actions recorded.");
2855
+ return;
2856
+ }
2857
+ for (const entry of report.entries) {
2858
+ console.log(`${entry.created_at} UTC ${entry.actor_id} -> target #${entry.target_project_id}\n` +
2859
+ ` ${entry.operation} ${entry.resource_type} #${entry.resource_id}\n ${entry.summary}`);
2860
+ }
2861
+ }
2862
+ function cmdPortfolio(argv) {
2863
+ const parsed = parseArgs(argv, { flags: ["--json"] });
2864
+ if (parsed.positional.length > 0)
2865
+ parsed.unknown.push(parsed.positional[0]);
2866
+ rejectUnknownFlags("portfolio", parsed, "--json");
2867
+ const report = collectPortfolio();
2868
+ if (parsed.flags.has("--json")) {
2869
+ console.log(JSON.stringify(report));
2870
+ return;
2871
+ }
2872
+ if (report.projects.length === 0) {
2873
+ console.log("No registered projects.");
2874
+ return;
2875
+ }
2876
+ for (const p of report.projects) {
2877
+ const w = p.workers;
2878
+ const t = p.todos;
2879
+ console.log(`${p.lane.padEnd(14)} ${p.name} (#${p.id})${p.root_exists ? "" : " [root missing]"}\n` +
2880
+ ` why: ${p.reasons.join(", ")}\n` +
2881
+ ` lead ${leadText(p)}; workers ${w.working} working, ${w.idle} idle, ${w.needs_input} needs input, ` +
2882
+ `${w.other} other, ${w.unreachable} unreachable, ${w.unconfirmed} unconfirmed\n` +
2883
+ ` todos ${t.open} open, ${t.in_progress} in progress, ${t.blocked} blocked, ${t.high} high; ` +
2884
+ `needs human ${p.needs_human}; wakes ${p.wakes.pending} pending, ${p.wakes.overdue} overdue\n` +
2885
+ ` last activity ${p.last_activity_at} UTC`);
2886
+ }
2887
+ const l = report.totals.lanes;
2888
+ console.log(`\n${report.totals.projects} project(s): ${l.waiting_on_you} waiting on you, ${l.stuck} stuck, ${l.moving} moving, ${l.quiet} quiet`);
2889
+ }
2579
2890
  function cmdBackups() {
2580
2891
  const snapshots = listSnapshots(dataDir);
2581
2892
  if (snapshots.length === 0) {
@@ -2743,9 +3054,7 @@ function requireFlagValues(command, parsed) {
2743
3054
  process.exit(1);
2744
3055
  }
2745
3056
  function cmdTodos(argv) {
2746
- const project = pinnedOrCwdProject();
2747
- if (!project)
2748
- return;
3057
+ const project = resolveProjectForRead("todos");
2749
3058
  const all = argv.includes("--all");
2750
3059
  const statusIdx = argv.indexOf("--status");
2751
3060
  const status = flagValue(argv, "--status");
@@ -2789,9 +3098,7 @@ function cmdTodos(argv) {
2789
3098
  }
2790
3099
  }
2791
3100
  function cmdTodo(argv) {
2792
- const project = pinnedOrCwdProject();
2793
- if (!project)
2794
- return;
3101
+ const project = resolveProjectForRead("todo");
2795
3102
  const id = Number(argv.find((a) => !a.startsWith("--")));
2796
3103
  if (!Number.isInteger(id)) {
2797
3104
  console.log("Usage: hive todo <id> (run inside the project)");
@@ -2832,7 +3139,7 @@ function cmdTodo(argv) {
2832
3139
  }
2833
3140
  }
2834
3141
  function cmdPads() {
2835
- const project = resolveProject();
3142
+ const project = resolveProjectForRead("pads");
2836
3143
  const pads = listActivePads(project.id);
2837
3144
  if (pads.length === 0) {
2838
3145
  console.log(`No pads in project "${project.name}".`);
@@ -2851,7 +3158,7 @@ function cmdPad(argv) {
2851
3158
  console.log("Usage: hive pad <name> [--edit | --save [file]] (run inside the project)");
2852
3159
  process.exit(1);
2853
3160
  }
2854
- const project = resolveProject();
3161
+ const project = parsed.flags.has("--save") ? resolveProjectForWrite("hive pad --save") : resolveProjectForRead("pad");
2855
3162
  const pad = getActivePadByName(project.id, name);
2856
3163
  if (!pad) {
2857
3164
  console.log(`No pad named "${name}" in project "${project.name}". List them with: hive pads`);
@@ -2921,6 +3228,11 @@ function cmdPad(argv) {
2921
3228
  }
2922
3229
  process.stdout.write(withTrailingNewline(pad.content));
2923
3230
  }
3231
+ process.stdout.on("error", (error) => {
3232
+ if (error.code === "EPIPE")
3233
+ process.exit(0);
3234
+ throw error;
3235
+ });
2924
3236
  const args = process.argv.slice(2);
2925
3237
  let command = args[0] ?? "lead";
2926
3238
  let rest = args.slice(1);
@@ -2945,7 +3257,7 @@ if (command === "--version" || command === "-v") {
2945
3257
  process.exit(0);
2946
3258
  }
2947
3259
  const COMMANDS = [
2948
- "lead", "init", "attach", "start", "stop", "show", "hide", "status", "setup", "upgrade", "doctor",
3260
+ "lead", "queen", "queen-audit", "init", "attach", "start", "stop", "show", "hide", "status", "portfolio", "next", "setup", "upgrade", "doctor",
2949
3261
  LEAD_PANE_EXITED_VERB,
2950
3262
  "pads", "pad", "todos", "todo", "backups", "restore", "runbook", "posture", "profile", "kickoff", "statusline",
2951
3263
  ];
@@ -2969,6 +3281,9 @@ try {
2969
3281
  case "lead":
2970
3282
  await cmdLead(rest);
2971
3283
  break;
3284
+ case "queen":
3285
+ await cmdQueen(rest);
3286
+ break;
2972
3287
  case "init":
2973
3288
  await cmdInit(rest);
2974
3289
  break;
@@ -2993,6 +3308,15 @@ try {
2993
3308
  case "status":
2994
3309
  cmdStatus();
2995
3310
  break;
3311
+ case "portfolio":
3312
+ cmdPortfolio(rest);
3313
+ break;
3314
+ case "queen-audit":
3315
+ cmdQueenAudit(rest);
3316
+ break;
3317
+ case "next":
3318
+ await cmdNext(rest);
3319
+ break;
2996
3320
  case "setup":
2997
3321
  cmdSetup(rest);
2998
3322
  break;