@cmgmyr/hive 1.10.1 → 1.11.1

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 CHANGED
@@ -70,6 +70,7 @@ hive is a personal daily-driver tool, released low-key. It is single-user by des
70
70
  | [Dashboard](docs/dashboard.md) | The generated dashboard: enabling it, where it lives, what it shows |
71
71
  | [Install details](docs/install.md) | The interpreter pin, iTerm settings, the status line, MCP scope, codex workers, updating, uninstalling |
72
72
  | [Troubleshooting](docs/troubleshooting.md) | Common errors and their fixes |
73
+ | [Security](docs/security.md) | What hive runs, writes and fetches on your machine, with the code to check |
73
74
  | [Tools](docs/tools.md) | The MCP tools: what each does and when to use it |
74
75
  | [tmux settings](docs/tmux.md) | Attach modes, pane options, and what to put in `~/.tmux.conf` |
75
76
  | [Development](docs/development.md) | Building and testing hive itself |
@@ -121,6 +121,8 @@ export function buildCrewView(snapshot, previousView, nowMs) {
121
121
  const { wakes } = snapshot;
122
122
  const workers = snapshot.lanes.filter((l) => l.worker).length;
123
123
  const footer = [{ text: nextLine(wakes.next, nowMs) }];
124
+ const questionsPending = snapshot.questions?.pending ?? 0;
125
+ if (questionsPending > 0) footer.push({ text: `questions pending: ${questionsPending}` });
124
126
  if (wakes.watching.length > 0) footer.push({ text: `watching: ${wakes.watching.map(watchText).join(", ")}` });
125
127
  else if (workers > 0 && !covered(snapshot)) footer.push({ text: "unwatched: no standing watch on running workers", amber: true });
126
128
  return {
@@ -1 +1 @@
1
- {"version":"1.10.1","sha":"47de74a","dirty":false,"build_id":"8d90ac219e940dac865c9770e28e1686dbed96d3d7ec219884aa4b6aa328dab5"}
1
+ {"version":"1.11.1","sha":"59218ca","dirty":false,"build_id":"caa5cf415058468f8a1cd7152eac5af2255c5aed0e53f33e637bd75e3200fda0"}
package/dist/cli.js CHANGED
@@ -17,6 +17,7 @@ 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 { staleHiveServers } from "./staleServers.js";
20
21
  import { addProject, agentProjectPin, assertQueenCrossProjectWrite, captureQueenWriteIdentity, currentActor, effectiveProjectId, findProjectForCwd, findProjectForDir, getProjectByPath, gitPrimaryRoot, getProject, listProjects, QUEEN_PROFILE, queenHomeDir, registeredAncestor, takeRegistrationNotice, } from "./context.js";
21
22
  import { confirmQueenWrite, listQueenAudit } from "./queenAudit.js";
22
23
  import { formatRowCounts, projectRowCounts, removeProject } from "./projectRemove.js";
@@ -37,8 +38,10 @@ import { collectCrew, crewStoreProblem } from "./crew.js";
37
38
  import { describeVisibility, formatLocal, processCounts } from "./dashboard.js";
38
39
  import { runningCommandRow, runningCommandRows, snapshotProcesses, stopAllProcesses, stopLine, stopProcess, STOP_REASONS, } from "./processes.js";
39
40
  import { activeProfile, agentVarKeys, BUILT_IN_PROJECT_YML, configHash, loadProjectYml, mergedProjectVars, NO_PROFILE, PROJECT_YML_KEYS, resolveCommandDir, resolveFirstMessage, } from "./projectYml.js";
40
- import { writeProjectPosture } from "./brief.js";
41
- import { carriesNameFlag, harnessFor, hasTranscriptSignal, paneClassifierFor, transcriptDirFor } from "./harnesses.js";
41
+ import { appendClaudeLeadArgs, crewPluginDir, isTrusted, leadIdentityEnv, renderLeadPosture } from "./leadLaunch.js";
42
+ import { handoffStatusSegment } from "./leadHandoff.js";
43
+ import { cmdLeadHandoff, runHandoffGrace, runHandoffStart } from "./leadHandoffCli.js";
44
+ import { harnessFor, hasTranscriptSignal, paneClassifierFor, transcriptDirFor } from "./harnesses.js";
42
45
  import { codexHomeDir, codexInstructionsPhrase, ensureCodexHome, reapCodexHome } from "./codexHome.js";
43
46
  import { FIRST_MESSAGE_SHA_ENV, firstMessageDigest } from "./firstMessage.js";
44
47
  import { ageSecondsSince, deriveProvenance, describeForHuman, describeLastLogEvent, humanizeAge, lastLogEvent, lastPermissionMode, reportsAgentStateLog, } from "./stateProvenance.js";
@@ -46,6 +49,7 @@ import { awaitingFirstPromptSql } from "./firstPrompt.js";
46
49
  import { checkoutRoot, createProfile, forkProfile, PROFILE_FILES, ProfileError, profileExists, profileFileNames, profileNames, profileStatus, readProfileFile, referencedPads, referencedPaths, renderProfileFile, resolveProfileFile, REWRITE_THRESHOLD, templateVars, userProfilesDir, } from "./profiles.js";
47
50
  import { getTodoDetail, listTodoSummaries, OPEN_BLOCKERS_SQL, TODO_STATUSES, } from "./tools/todos.js";
48
51
  import { createPad, getActivePadByName, listActivePads, overwritePadContent, } from "./tools/pads.js";
52
+ import { answerQuestion, listQuestions } from "./questions.js";
49
53
  function usage() {
50
54
  console.log(`hive — run a crew of Claude Code and Codex workers from one lead session
51
55
 
@@ -74,7 +78,7 @@ Usage:
74
78
  snapshot; read-only, writes nothing
75
79
  hive portfolio [--json] one row per registered project: lane, lead, workers,
76
80
  todos, needs-human count, wakes; read-only
77
- hive queen-audit confirmed queen writes, newest first; 30-day history
81
+ hive queen-audit queen writes and refused calls, newest first; 30-day history
78
82
  [--project-id <id>] filter the target project (queen only across projects)
79
83
  [--limit <n>] [--json] default 20, maximum 100; JSON prints one object
80
84
  hive next [--print] attach to the lead of the project that needs you most
@@ -99,6 +103,10 @@ Usage:
99
103
  hive todos [--all] [--status <s>] [--tag <t>]
100
104
  list this project's todos; open work by default
101
105
  hive todo <id> print one todo in full, comments included
106
+ hive questions [--json] list pending and defaulted questions
107
+ hive answer <id> <answer> [--notes <text>]
108
+ record an answer to a pending question
109
+ use -- before an answer that starts with a dash
102
110
  hive backups list automatic store snapshots
103
111
  hive restore <name> [--yes] [--force] overwrite the live store from a snapshot
104
112
  hive project rm <id|path> [--yes]
@@ -109,6 +117,7 @@ Usage:
109
117
  hive kickoff [--explain] SessionStart hook output; silent unless this is a lead checkout
110
118
  hive profile [list|path|fork|create|read] standing instructions shared across projects
111
119
  hive statusline one-line store summary; silent outside hive projects
120
+ hive lead-handoff a lead confirms its persisted handoff pad (run from a handoff request)
112
121
 
113
122
  hive lead reads hive.yml from the project root when present; hive init
114
123
  writes this starter file (uncomment what you need):
@@ -204,12 +213,6 @@ async function ensureTrusted(projectId, name, command, dir, env) {
204
213
  db.prepare("INSERT OR IGNORE INTO command_trust (project_id, name, config_hash) VALUES (?, ?, ?)").run(projectId, name, hash);
205
214
  return true;
206
215
  }
207
- function isTrusted(projectId, name, command, dir, env) {
208
- const hash = configHash(name, command, dir, env);
209
- return !!db
210
- .prepare("SELECT 1 FROM command_trust WHERE project_id = ? AND name = ? AND config_hash = ?")
211
- .get(projectId, name, hash);
212
- }
213
216
  function startYmlCommand(project, name, proc, config) {
214
217
  if (isReservedAgentName(name)) {
215
218
  return `skipped: "lead" is reserved for this project's lead session and cannot be used as a process name`;
@@ -426,18 +429,9 @@ async function cmdLead(argv) {
426
429
  // Posture is rendered once, then delivered through whichever channel this harness has: a
427
430
  // claude lead gets it as --append-system-prompt-file below; a codex lead gets the identical
428
431
  // text as config.toml's developer_instructions, built after ensureLeadRow (it needs leadActorId).
429
- const profile = activeProfile(config);
430
- let renderedPosture = null;
431
- let postureSource;
432
- if (profile) {
433
- const posture = resolveProfileFile(profile, "posture.md");
434
- if (!posture) {
435
- console.log(`! profile "${profile}" has no posture.md on this machine; starting without it.`);
436
- }
437
- else {
438
- renderedPosture = renderProfileFile(profile, "posture.md", mergedProjectVars(config)) ?? "";
439
- postureSource = posture.source;
440
- }
432
+ const { profile, rendered: renderedPosture, source: postureSource } = renderLeadPosture(config);
433
+ if (profile && renderedPosture === null) {
434
+ console.log(`! profile "${profile}" has no posture.md on this machine; starting without it.`);
441
435
  }
442
436
  if (leadHarness.needsHome) {
443
437
  // Wired below, once leadActorId is known - a codex lead's hooks/posture/triage all live in
@@ -451,21 +445,19 @@ async function cmdLead(argv) {
451
445
  }
452
446
  }
453
447
  else {
454
- if (!carriesNameFlag(leadCommand.trim().split(/\s+/))) {
455
- leadCommand += ` --name ${shellQuote(project.name)}`;
456
- }
457
- leadCommand += ` ${leadHarness.briefDelivery.settingsArgs(hooksPath).map(shellQuote).join(" ")}`;
458
- if (config?.lead_sidebar === true && leadHarness.name === "claude") {
459
- leadCommand += ` ${["--plugin-dir", crewPluginDir()].map(shellQuote).join(" ")}`;
460
- }
448
+ ({ command: leadCommand, promptSuffix } = appendClaudeLeadArgs({
449
+ command: leadCommand,
450
+ harness: leadHarness,
451
+ projectId: project.id,
452
+ projectName: project.name,
453
+ hooksPath,
454
+ sidebarPluginDir: config?.lead_sidebar === true && leadHarness.name === "claude" ? crewPluginDir() : null,
455
+ posture: renderedPosture,
456
+ firstMessage,
457
+ }));
461
458
  if (renderedPosture !== null) {
462
- const posturePath = writeProjectPosture(project.id, renderedPosture);
463
- leadCommand += ` ${leadHarness.briefDelivery.systemPromptArgs(posturePath).map(shellQuote).join(" ")}`;
464
459
  console.log(`- profile: ${profile} (${postureSource} posture; see it with: hive posture)`);
465
460
  }
466
- if (leadHarness.initialPromptArgs && firstMessage !== "") {
467
- promptSuffix = ` ${leadHarness.initialPromptArgs(firstMessage).map(shellQuote).join(" ")}`;
468
- }
469
461
  }
470
462
  const windowName = project.name;
471
463
  const { agentId: leadAgentId, actorId: leadActorId, previousTarget, previousSocket, previousPanePid, previousCommand, previousCodexHome, casExpected, casSocket, casPid, } = ensureLeadRow(project, leadCommand);
@@ -475,14 +467,7 @@ async function cmdLead(argv) {
475
467
  // actually starts under it) and correct regardless of whether this invocation ends up creating
476
468
  // a pane or adopting one. Which home ends up orphaned - this one, or the previous invocation's -
477
469
  // is decided after createdPane is known, below.
478
- const leadEnv = {
479
- HIVE_AGENT_ID: leadActorId,
480
- HIVE_AGENT_NAME: LEAD_NAME,
481
- HIVE_LEAD: "1",
482
- HIVE_DATA_DIR: dataDir,
483
- HIVE_PROJECT_LOCK: "",
484
- HIVE_PROJECT_PATH: "",
485
- };
470
+ const leadEnv = leadIdentityEnv(leadActorId);
486
471
  let newCodexHomeKey;
487
472
  if (leadHarness.needsHome) {
488
473
  newCodexHomeKey = randomUUID();
@@ -1288,9 +1273,6 @@ function cmdProfile(argv) {
1288
1273
  throw e;
1289
1274
  }
1290
1275
  }
1291
- function crewPluginDir() {
1292
- return join(checkoutRoot, "claude-plugin", "crew");
1293
- }
1294
1276
  const DASHBOARD_OPENED_KV_KEY = "hive:dashboard_opened";
1295
1277
  const DASHBOARD_OPENED_TTL_SECONDS = 8 * 60 * 60;
1296
1278
  function maybeOpenDashboard(project, dashboardEnabled) {
@@ -1436,6 +1418,8 @@ function resolveNamedProcess(argv, verb) {
1436
1418
  }
1437
1419
  const LEAD_PANE_EXITED_HOOK = "pane-exited";
1438
1420
  const LEAD_PANE_EXITED_VERB = "lead-pane-exited";
1421
+ const LEAD_HANDOFF_GRACE_VERB = "lead-handoff-grace";
1422
+ const LEAD_HANDOFF_START_VERB = "lead-handoff-start";
1439
1423
  // tmux runs a hook with the SERVER's environment and no login shell, so a bare `hive` resolves to
1440
1424
  // nothing (the iTerm rule in .claude/rules/tmux-and-panes.md) and HIVE_DATA_DIR is not there at all
1441
1425
  // - without it the backstop would open the DEFAULT store and find no project. Absolute interpreter,
@@ -2364,6 +2348,18 @@ function cmdDoctor(argv) {
2364
2348
  }
2365
2349
  return `${dataDir} (schema v${n})`;
2366
2350
  });
2351
+ check("stale hive servers", () => {
2352
+ const { applied_at: appliedAt } = db.prepare("SELECT MAX(applied_at) AS applied_at FROM migrations").get();
2353
+ const result = staleHiveServers(appliedAt, dataDir);
2354
+ if ("skipped" in result) {
2355
+ info("stale hive servers", `check skipped: ${result.skipped}`);
2356
+ return "check skipped";
2357
+ }
2358
+ for (const server of result.servers) {
2359
+ warn("stale hive server", `pid ${server.pid} started ${server.startedAt} (parent pid ${server.ppid}: ${server.parentCommand ?? "unknown"})`, `command: ${server.command}`, "restart that session to load the current build");
2360
+ }
2361
+ return `${result.servers.length} stale server${result.servers.length === 1 ? "" : "s"}`;
2362
+ });
2367
2363
  check("hooks file", () => ensureHooksFile());
2368
2364
  const here = findProjectForCwd();
2369
2365
  reportDispatcher();
@@ -2810,11 +2806,13 @@ function cmdStatusline() {
2810
2806
  parts.push("store ahead (update hive)");
2811
2807
  let turns = null;
2812
2808
  let budget = null;
2809
+ let handoff = null;
2813
2810
  try {
2814
2811
  const actorId = process.env.HIVE_AGENT_ID;
2815
2812
  if (actorId) {
2816
- const lead = db.prepare("SELECT 1 AS lead FROM agents WHERE project_id = ? AND actor_id = ? AND kind = 'lead' AND status = 'running'").get(project.id, actorId);
2813
+ const lead = db.prepare("SELECT id AS lead FROM agents WHERE project_id = ? AND actor_id = ? AND kind = 'lead' AND status = 'running'").get(project.id, actorId);
2817
2814
  if (lead) {
2815
+ handoff = handoffStatusSegment(lead.lead);
2818
2816
  let inputPath = "";
2819
2817
  if (!process.stdin.isTTY) {
2820
2818
  try {
@@ -2834,6 +2832,8 @@ function cmdStatusline() {
2834
2832
  const color = budget === null ? "" : turns >= budget.stop ? "\x1b[31m" : turns >= budget.warn ? "\x1b[33m" : "";
2835
2833
  parts.push(`${color}turns ${turns}${color ? "\x1b[0m" : ""}`);
2836
2834
  }
2835
+ if (handoff !== null)
2836
+ parts.push(handoff);
2837
2837
  console.log(`\x1b[33m⬡\x1b[0m \x1b[2mhive:\x1b[0m ${parts.join(" \x1b[2m·\x1b[0m ")}`);
2838
2838
  }
2839
2839
  function padSlug(name) {
@@ -2857,19 +2857,20 @@ function canonicalPath(path) {
2857
2857
  function nextCandidates() {
2858
2858
  const queenRoot = canonicalPath(join(storeDir(), "queen"));
2859
2859
  const byCount = (a, b) => b - a;
2860
+ const waitsOnYou = (p) => p.needs_human + p.questions_pending;
2860
2861
  return collectPortfolio()
2861
- .projects.filter((p) => (p.lane === "waiting_on_you" || p.lane === "stuck" || (p.lane === "moving" && p.needs_human > 0)) &&
2862
+ .projects.filter((p) => (p.lane === "waiting_on_you" || p.lane === "stuck" || (p.lane === "moving" && waitsOnYou(p) > 0)) &&
2862
2863
  canonicalPath(p.root) !== queenRoot)
2863
2864
  .sort((a, b) => {
2864
- const tier = (p) => (p.lane === "waiting_on_you" ? 0 : p.needs_human > 0 ? 1 : 2);
2865
+ const tier = (p) => (p.lane === "waiting_on_you" ? 0 : waitsOnYou(p) > 0 ? 1 : 2);
2865
2866
  if (tier(a) !== tier(b))
2866
2867
  return tier(a) - tier(b);
2867
2868
  if (a.lane === "waiting_on_you") {
2868
- return (byCount(a.needs_human, b.needs_human) ||
2869
+ return (byCount(waitsOnYou(a), waitsOnYou(b)) ||
2869
2870
  a.last_activity_at.localeCompare(b.last_activity_at) ||
2870
2871
  a.id - b.id);
2871
2872
  }
2872
- return (byCount(a.needs_human, b.needs_human) ||
2873
+ return (byCount(waitsOnYou(a), waitsOnYou(b)) ||
2873
2874
  byCount(a.todos.blocked_in_progress, b.todos.blocked_in_progress) ||
2874
2875
  byCount(a.wakes.overdue, b.wakes.overdue) ||
2875
2876
  byCount(a.workers.needs_input, b.workers.needs_input) ||
@@ -2948,7 +2949,7 @@ function cmdQueenAudit(argv) {
2948
2949
  return;
2949
2950
  }
2950
2951
  if (report.entries.length === 0) {
2951
- console.log("No queen actions recorded.");
2952
+ console.log("No queen audit entries recorded.");
2952
2953
  return;
2953
2954
  }
2954
2955
  for (const entry of report.entries) {
@@ -3006,7 +3007,7 @@ function cmdPortfolio(argv) {
3006
3007
  ` lead ${leadText(p)}; workers ${w.working} working, ${w.idle} idle, ${w.needs_input} needs input, ` +
3007
3008
  `${w.other} other, ${w.unreachable} unreachable, ${w.unconfirmed} unconfirmed\n` +
3008
3009
  ` todos ${t.open} open, ${t.in_progress} in progress, ${t.blocked} blocked, ${t.high} high; ` +
3009
- `needs human ${p.needs_human}; wakes ${p.wakes.pending} pending, ${p.wakes.overdue} overdue\n` +
3010
+ `needs human ${p.needs_human}; questions ${p.questions_pending} pending; wakes ${p.wakes.pending} pending, ${p.wakes.overdue} overdue\n` +
3010
3011
  ` last activity ${p.last_activity_at} UTC`);
3011
3012
  }
3012
3013
  const l = report.totals.lanes;
@@ -3314,6 +3315,70 @@ function cmdTodo(argv) {
3314
3315
  }
3315
3316
  }
3316
3317
  }
3318
+ function cmdQuestions(argv) {
3319
+ const parsed = parseArgs(argv, { flags: ["--json"] });
3320
+ if (parsed.positional.length > 0)
3321
+ parsed.unknown.push(parsed.positional[0]);
3322
+ rejectUnknownFlags("questions", parsed, "--json");
3323
+ const project = resolveProjectForRead("questions");
3324
+ const questions = listQuestions(project.id);
3325
+ if (parsed.flags.has("--json")) {
3326
+ console.log(JSON.stringify({ questions }));
3327
+ return;
3328
+ }
3329
+ if (questions.length === 0) {
3330
+ console.log("No pending or defaulted questions.");
3331
+ return;
3332
+ }
3333
+ for (const q of questions) {
3334
+ console.log(`#${q.id} [${q.state}] ${q.header}`);
3335
+ console.log(`Question: ${q.question}`);
3336
+ console.log(`Default: ${q.default_answer}`);
3337
+ if (q.options) {
3338
+ console.log("Options:");
3339
+ for (const option of q.options) {
3340
+ console.log(` ${option.label}${option.recommended ? " (Recommended)" : ""}: ${option.description}`);
3341
+ }
3342
+ }
3343
+ }
3344
+ }
3345
+ function cmdAnswer(argv) {
3346
+ const endOfFlags = argv.indexOf("--");
3347
+ const optionArgs = endOfFlags === -1 ? argv : argv.slice(0, endOfFlags);
3348
+ if (optionArgs.filter((arg) => arg === "--notes").length > 1) {
3349
+ console.error("hive answer: --notes may be passed only once.");
3350
+ process.exit(1);
3351
+ }
3352
+ const parsed = parseArgs(argv, { valued: ["--notes"] });
3353
+ rejectUnknownFlags("answer", parsed, "--notes <text>");
3354
+ requireFlagValues("answer", parsed);
3355
+ if (parsed.values.has("--notes") && parsed.values.get("--notes") === undefined) {
3356
+ console.error("hive answer: --notes requires a value.");
3357
+ process.exit(1);
3358
+ }
3359
+ if (parsed.positional.length !== 2) {
3360
+ console.error("hive answer: usage: hive answer <positive id> <one answer argument> [--notes <text>]");
3361
+ process.exit(1);
3362
+ }
3363
+ const [rawId, answer] = parsed.positional;
3364
+ if (!/^\d+$/.test(rawId) || !Number.isSafeInteger(Number(rawId)) || Number(rawId) <= 0) {
3365
+ console.error(`hive answer: question id must be a positive safe integer, got ${JSON.stringify(rawId)}.`);
3366
+ process.exit(1);
3367
+ }
3368
+ const project = resolveProjectForRead("answer");
3369
+ const id = Number(rawId);
3370
+ const receipt = answerQuestion(project.id, currentActor(), id, answer, parsed.values.get("--notes"));
3371
+ console.log(`Question #${receipt.question_id} answered.`);
3372
+ if (receipt.wake_id === null) {
3373
+ console.log(`No wake queued: ${receipt.wake_skipped ?? "the asking session could not be reached"}.`);
3374
+ }
3375
+ else if (receipt.wake_rerouted_to) {
3376
+ console.log(`Wake #${receipt.wake_id} queued for ${receipt.wake_rerouted_to}; the asker is not running.`);
3377
+ }
3378
+ else {
3379
+ console.log(`Wake #${receipt.wake_id} queued for the asking session.`);
3380
+ }
3381
+ }
3317
3382
  function cmdPads() {
3318
3383
  const project = resolveProjectForRead("pads");
3319
3384
  const pads = listActivePads(project.id);
@@ -3435,7 +3500,10 @@ if (command === "--version" || command === "-v") {
3435
3500
  const COMMANDS = [
3436
3501
  "lead", "queen", "queen-audit", "init", "attach", "start", "stop", "show", "hide", "status", "portfolio", "crew", "next", "setup", "upgrade", "doctor",
3437
3502
  LEAD_PANE_EXITED_VERB,
3438
- "pads", "pad", "todos", "todo", "backups", "restore", "project", "runbook", "posture", "profile", "kickoff", "statusline",
3503
+ LEAD_HANDOFF_GRACE_VERB,
3504
+ LEAD_HANDOFF_START_VERB,
3505
+ "lead-handoff",
3506
+ "pads", "pad", "todos", "todo", "questions", "answer", "backups", "restore", "project", "runbook", "posture", "profile", "kickoff", "statusline",
3439
3507
  ];
3440
3508
  if (!COMMANDS.includes(command)) {
3441
3509
  if (command.startsWith("--")) {
@@ -3475,6 +3543,15 @@ try {
3475
3543
  case LEAD_PANE_EXITED_VERB:
3476
3544
  cmdLeadPaneExited(rest);
3477
3545
  break;
3546
+ case "lead-handoff":
3547
+ process.exitCode = await cmdLeadHandoff(rest);
3548
+ break;
3549
+ case LEAD_HANDOFF_GRACE_VERB:
3550
+ process.exitCode = await runHandoffGrace(rest);
3551
+ break;
3552
+ case LEAD_HANDOFF_START_VERB:
3553
+ process.exitCode = await runHandoffStart(rest);
3554
+ break;
3478
3555
  case "show":
3479
3556
  cmdShow(rest);
3480
3557
  break;
@@ -3517,6 +3594,12 @@ try {
3517
3594
  case "todo":
3518
3595
  cmdTodo(rest);
3519
3596
  break;
3597
+ case "questions":
3598
+ cmdQuestions(rest);
3599
+ break;
3600
+ case "answer":
3601
+ cmdAnswer(rest);
3602
+ break;
3520
3603
  case "backups":
3521
3604
  cmdBackups();
3522
3605
  break;
package/dist/context.js CHANGED
@@ -5,6 +5,8 @@ import { basename, dirname, join, resolve, sep } from "node:path";
5
5
  import { storeDir } from "./dataDir.js";
6
6
  import { db } from "./db.js";
7
7
  import { LEAD_KIND } from "./spawn.js";
8
+ import { rowOwnership } from "./tmux.js";
9
+ import { recordQueenRefusal } from "./queenAudit.js";
8
10
  let selectedId = null;
9
11
  let cachedActorId = null;
10
12
  let lastTouchMs = 0;
@@ -24,7 +26,7 @@ export function currentActor() {
24
26
  return cachedActorId;
25
27
  }
26
28
  const envId = process.env.HIVE_AGENT_ID;
27
- const id = envId ?? `user:${userInfo().username}`;
29
+ const id = envId ?? `${HUMAN_ACTOR_PREFIX}${userInfo().username}`;
28
30
  const name = process.env.HIVE_AGENT_NAME ?? id;
29
31
  const kind = envId ? "agent" : "human";
30
32
  db.prepare(`INSERT INTO actors (id, name, kind, tmux_pane) VALUES (?, ?, ?, ?)
@@ -294,6 +296,30 @@ export function captureQueenWriteIdentity(targetProjectId) {
294
296
  return null;
295
297
  return { actor_id: process.env.HIVE_AGENT_ID, home_project_id: home };
296
298
  }
299
+ export function resolveQueenLeadTarget(operation, targetProjectId, ref, resolve) {
300
+ if (!captureQueenWriteIdentity(targetProjectId))
301
+ return resolve();
302
+ let agent;
303
+ try {
304
+ agent = resolve();
305
+ }
306
+ catch (e) {
307
+ throw queenRefusal(operation, targetProjectId, `recipient ${JSON.stringify(ref)} could not be resolved: ${e instanceof Error ? e.message : String(e)}`);
308
+ }
309
+ const ownership = rowOwnership(agent);
310
+ const problem = agent.project_id !== targetProjectId
311
+ ? `Agent ${agent.id} ("${agent.name}") belongs to project ${agent.project_id}, not project ${targetProjectId}`
312
+ : agent.kind !== LEAD_KIND
313
+ ? `Agent ${agent.id} ("${agent.name}") is kind "${agent.kind}", not a lead`
314
+ : agent.status !== "running"
315
+ ? `Agent ${agent.id} ("${agent.name}") is ${agent.status}, not running`
316
+ : ownership !== "live"
317
+ ? `Agent ${agent.id} ("${agent.name}") does not own a live pane (ownership: ${ownership})`
318
+ : null;
319
+ if (problem)
320
+ throw queenRefusal(operation, targetProjectId, problem);
321
+ return agent;
322
+ }
297
323
  export const QUEEN_REACH = {
298
324
  whoami: "read",
299
325
  help: "read",
@@ -330,6 +356,11 @@ export const QUEEN_REACH = {
330
356
  todo_unblock: "home",
331
357
  kv_set: "home",
332
358
  kv_delete: "home",
359
+ question_ask: "home",
360
+ question_get: "home",
361
+ question_list: "home",
362
+ question_update: "home",
363
+ question_answer: "home",
333
364
  lease_acquire: "home",
334
365
  lease_release: "home",
335
366
  agent_spawn: "home",
@@ -374,7 +405,7 @@ function foreignWriteProblem(reach, targetProjectId, args) {
374
405
  const lead = runningLead(targetProjectId);
375
406
  if (!lead)
376
407
  return "that project has no running lead to address";
377
- if (!namesLead(args.deliver_to, lead)) {
408
+ if (args.deliver_to == null) {
378
409
  return `deliver_to must name that project's running lead ("${lead.name}" or agent id ${lead.id})`;
379
410
  }
380
411
  return null;
@@ -397,8 +428,8 @@ function foreignWriteProblem(reach, targetProjectId, args) {
397
428
  const lead = runningLead(targetProjectId);
398
429
  if (!lead)
399
430
  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})`;
431
+ if (args.agent_id == null && args.name == null) {
432
+ return `the recipient must name that project's running lead ("${lead.name}" or agent id ${lead.id})`;
402
433
  }
403
434
  return null;
404
435
  }
@@ -441,7 +472,20 @@ export function assertQueenToolCall(tool, args) {
441
472
  function queenRefusal(operation, targetProjectId, problem) {
442
473
  const target = targetProjectId === null ? null : getProject(targetProjectId);
443
474
  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 ` +
475
+ const refusal = new Error(`${QUEEN_REFUSAL}: the queen cannot run ${operation} ${where}: ${problem}. Into another project the ` +
445
476
  "queen may only todo_create, todo_comment, send text or wakes to its running lead, start its lead " +
446
477
  "with `hive lead <path>`, and run project_prune or `hive project rm` on it. Everything else there is its lead's to do.");
478
+ if (targetProjectId !== null) {
479
+ const identity = captureQueenWriteIdentity(targetProjectId);
480
+ if (identity) {
481
+ try {
482
+ recordQueenRefusal(operation, targetProjectId, problem, identity);
483
+ }
484
+ catch {
485
+ // A refusal must survive an unavailable audit write.
486
+ }
487
+ }
488
+ }
489
+ return refusal;
447
490
  }
491
+ export const HUMAN_ACTOR_PREFIX = "user:";
package/dist/crew.js CHANGED
@@ -5,6 +5,7 @@ import { awaitingFirstPrompt } from "./firstPrompt.js";
5
5
  import { heldReasonLabel } from "./heldLabel.js";
6
6
  import { harnessFor, paneClassifierFor } from "./harnesses.js";
7
7
  import { NEEDS_HUMAN_TAG } from "./portfolio.js";
8
+ import { pendingQuestionCount } from "./questions.js";
8
9
  import { loadProjectYml } from "./projectYml.js";
9
10
  import { parseTags } from "./result.js";
10
11
  import { ACTIVE_TIMER_WHERE } from "./scheduler.js";
@@ -20,6 +21,8 @@ export function crewStoreProblem(database = db) {
20
21
  const columns = database.prepare("SELECT name FROM pragma_table_info('agents')").all();
21
22
  if (!columns.some((c) => c.name === "todo_id"))
22
23
  return "the store predates the worker-to-todo link (agents.todo_id)";
24
+ if (!tables.has("questions"))
25
+ return "the store has no questions table";
23
26
  return null;
24
27
  }
25
28
  function isoUtc(value) {
@@ -235,12 +238,26 @@ function tokenize(segment) {
235
238
  return tokens;
236
239
  }
237
240
  const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
241
+ const SHELL_PREFIX_KEYWORDS = new Set(["if", "elif", "while", "until", "do", "then", "else", "!", "{"]);
242
+ const SHELL_HEADER_KEYWORDS = new Set(["for", "select", "case"]);
243
+ const SHELL_CLOSING_KEYWORDS = new Set(["done", "fi", "esac", "}"]);
238
244
  function toSegment(segment) {
239
245
  const tokens = tokenize(segment);
240
246
  let start = 0;
241
247
  while (start < tokens.length && ENV_ASSIGNMENT.test(tokens[start]))
242
248
  start++;
243
- const rest = tokens.slice(start);
249
+ let rest = tokens.slice(start);
250
+ while (rest.length > 0) {
251
+ if (SHELL_HEADER_KEYWORDS.has(rest[0]) || SHELL_CLOSING_KEYWORDS.has(rest[0])) {
252
+ rest = [];
253
+ break;
254
+ }
255
+ if (!SHELL_PREFIX_KEYWORDS.has(rest[0]))
256
+ break;
257
+ rest = rest.slice(1);
258
+ while (rest.length > 0 && ENV_ASSIGNMENT.test(rest[0]))
259
+ rest = rest.slice(1);
260
+ }
244
261
  return { tokens: rest[0] === "cd" ? [] : rest, writes: writesToFile(segment) };
245
262
  }
246
263
  function segmentsOf(command) {
@@ -272,7 +289,7 @@ function matchesPrefix(tokens, matcher) {
272
289
  return tokens.length >= matcher.tokens.length && matcher.tokens.every((re, i) => re.test(tokens[i]));
273
290
  }
274
291
  const READING_EXES = new Set(["rg", "grep", "cat", "head", "tail", "sed"]);
275
- const FALLBACK_SKIP = new Set(["printf", "echo", "true", ":"]);
292
+ const FALLBACK_SKIP = new Set(["printf", "echo", "true", ":", "[", "[[", "test"]);
276
293
  const NODE_RUNNERS = new Set(["npm", "pnpm", "yarn"]);
277
294
  const TEST_BINARIES = new Set(["pest", "phpunit", "vitest", "jest"]);
278
295
  const BUILD_BINARIES = new Set(["pint", "phpstan"]);
@@ -482,6 +499,7 @@ export function collectCrew(project, now) {
482
499
  read_at: now.toISOString(),
483
500
  lanes,
484
501
  needs_you: needsYou,
502
+ questions: { pending: pendingQuestionCount(project.id) },
485
503
  wakes: {
486
504
  pending: pending.length,
487
505
  next: pending[0]
package/dist/dashboard.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { createRequire } from "node:module";
2
3
  import { db } from "./db.js";
3
4
  import { getProject } from "./context.js";
4
5
  export const describeVisibility = (visibility) => visibility === null ? "running" : visibility === "window" ? "own window" : visibility;
@@ -12,6 +13,7 @@ export function processCounts(procs) {
12
13
  }
13
14
  import { awaitingFirstPrompt } from "./firstPrompt.js";
14
15
  import { SLUG_MAX_LEN, cutToUnitBudget } from "./slug.js";
16
+ const requireFromHere = createRequire(import.meta.url);
15
17
  export const TODO_CAP = 100;
16
18
  export const AGENT_CAP = 50;
17
19
  export const WAKE_CAP = 50;
@@ -449,6 +451,45 @@ function renderWakesSection(index, projectId) {
449
451
  const body = renderCappedList(shown.length, all.length, "pending wakes", "wakes", rows, emptyState("No pending wakes.", "Scheduled and idle-triggered check-ins from wake_set show up here."));
450
452
  return section(index, "wakes", "Wakes", `${all.length} pending`, "bell", String(shown.length), body);
451
453
  }
454
+ function questionAge(createdAt) {
455
+ const timestamp = Date.parse(`${createdAt.replace(" ", "T")}Z`);
456
+ if (!Number.isFinite(timestamp))
457
+ return "unknown";
458
+ const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
459
+ if (seconds >= 86400)
460
+ return `${Math.floor(seconds / 86400)}d ago`;
461
+ if (seconds >= 3600)
462
+ return `${Math.floor(seconds / 3600)}h ago`;
463
+ if (seconds >= 60)
464
+ return `${Math.floor(seconds / 60)}m ago`;
465
+ return "now";
466
+ }
467
+ function renderQuestionsSection(index, projectId) {
468
+ // A static import of questions.js cycles through tools/wakes.ts and scheduler.ts.
469
+ const { listQuestions, pendingQuestionCount } = requireFromHere("./questions.js");
470
+ const questions = listQuestions(projectId);
471
+ if (questions.length === 0)
472
+ return "";
473
+ const pendingCount = pendingQuestionCount(projectId);
474
+ const rows = questions.map((question) => {
475
+ const options = question.kind === "free_text"
476
+ ? '<p class="question-options">free text</p>'
477
+ : `<ul class="question-options">${(question.options ?? []).map((option) => `<li>${prose(option.label)}${option.recommended ? ' <span class="recommended">recommended</span>' : ""}` +
478
+ `<span class="question-option-description">${prose(option.description)}</span></li>`).join("")}</ul>`;
479
+ const ref = question.ref ? `<span class="question-ref">${question.ref.kind === "todo" ? "todo" : "PR"} #${question.ref.number}</span>` : "";
480
+ const state = question.state === "defaulted"
481
+ ? '<span class="question-state">defaulted - proceeding on the default</span>'
482
+ : '<span class="question-state">pending</span>';
483
+ return (`<li class="question" data-question="${question.id}"><div class="question-head">${mono(`#${question.id}`)} ` +
484
+ `<strong>${escapeHtml(question.header)}</strong> ${state}</div>` +
485
+ `<p class="question-text">${prose(question.question)}</p>${options}` +
486
+ `<p class="question-default">Default answer: ${prose(question.default_answer)}</p>` +
487
+ `<p class="question-meta">Asked by ${mono(question.asker)} · ${escapeHtml(questionAge(question.created_at))}` +
488
+ `${ref ? ` · ${ref}` : ""}</p>` +
489
+ `<p class="question-answer">To answer: <code>hive answer ${question.id}</code></p></li>`);
490
+ }).join("\n");
491
+ return section(index, "questions", "Questions", `${pendingCount} pending`, "inbox", String(pendingCount), `<ul class="question-list">${rows}</ul>`);
492
+ }
452
493
  function fetchCommentActivity(projectId) {
453
494
  const rows = db
454
495
  .prepare(`SELECT c.id, c.body, c.author, c.created_at, t.id AS todo_id, t.title AS todo_title,
@@ -682,6 +723,15 @@ export const SECTION_STYLE = `
682
723
  .priority-low { color: var(--fg-subtle); }
683
724
  .blockers { font-size: 0.8125rem; color: var(--fg-muted); margin: 0 0 0.45rem 0.6rem; }
684
725
  .wake-body { color: var(--fg); font-size: 0.875rem; overflow-wrap: anywhere; }
726
+ .question-list { display: grid; gap: 0.85rem; list-style: none; margin: 0; padding: 0; }
727
+ .question { border-bottom: 1px solid var(--border); padding-bottom: 0.85rem; overflow-wrap: anywhere; }
728
+ .question:last-child { border-bottom: 0; padding-bottom: 0; }
729
+ .question-head, .question-meta, .question-answer, .question-default, .question-text, .question-options { margin: 0.2rem 0; }
730
+ .question-head, .question-meta, .question-answer { font-size: 0.8125rem; }
731
+ .question-state, .question-ref, .recommended { color: var(--fg-muted); font-size: 0.75rem; }
732
+ .question-options { padding-left: 1.2rem; }
733
+ .question-option-description { display: block; padding-left: 0.5rem; color: var(--fg-muted); }
734
+ .question-text { overflow-wrap: anywhere; white-space: pre-wrap; }
685
735
  .activity-body { font-size: 0.8125rem; color: var(--fg-muted); white-space: pre-wrap;
686
736
  overflow-wrap: anywhere; margin-top: 0.1rem; }
687
737
 
@@ -1171,7 +1221,9 @@ function buildDashboard(projectId, procs) {
1171
1221
  const project = fetchProject(projectId);
1172
1222
  const stats = fetchDayStats(projectId);
1173
1223
  const index = [];
1224
+ const questionsSection = renderQuestionsSection(index, projectId);
1174
1225
  const cards = [
1226
+ ...(questionsSection ? [questionsSection] : []),
1175
1227
  renderThroughputSection(index, stats),
1176
1228
  renderBoardSection(index, projectId),
1177
1229
  renderTodosSection(index, projectId),