@cmgmyr/hive 0.1.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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/claude-plugin/.claude-plugin/plugin.json +7 -0
  4. package/claude-plugin/README.md +47 -0
  5. package/claude-plugin/hooks/hooks.json +14 -0
  6. package/claude-plugin/kickoff.mjs +41 -0
  7. package/claude-plugin/skills/cleanup/SKILL.md +60 -0
  8. package/dist/abi.js +220 -0
  9. package/dist/abiProbe.js +9 -0
  10. package/dist/backgroundTasks.js +51 -0
  11. package/dist/backup.js +378 -0
  12. package/dist/brief.js +113 -0
  13. package/dist/build-info.json +1 -0
  14. package/dist/claudeDir.js +5 -0
  15. package/dist/cli.js +2901 -0
  16. package/dist/codexHome.js +297 -0
  17. package/dist/config.js +72 -0
  18. package/dist/context.js +256 -0
  19. package/dist/contextCheckpoint.js +55 -0
  20. package/dist/dashboard.js +1158 -0
  21. package/dist/dataDir.js +89 -0
  22. package/dist/db.js +421 -0
  23. package/dist/dispatcher.js +150 -0
  24. package/dist/firstPrompt.js +6 -0
  25. package/dist/harnesses.js +173 -0
  26. package/dist/help.js +353 -0
  27. package/dist/hook.js +197 -0
  28. package/dist/hooks.js +50 -0
  29. package/dist/index.js +34 -0
  30. package/dist/kickoff.js +191 -0
  31. package/dist/leadMessage.js +98 -0
  32. package/dist/mcpConfig.js +121 -0
  33. package/dist/processes.js +161 -0
  34. package/dist/profiles.js +278 -0
  35. package/dist/projectYml.js +254 -0
  36. package/dist/prompts.js +18 -0
  37. package/dist/ptys.js +146 -0
  38. package/dist/result.js +53 -0
  39. package/dist/scheduler.js +1622 -0
  40. package/dist/sessionProbe.js +131 -0
  41. package/dist/slug.js +29 -0
  42. package/dist/spawn.js +370 -0
  43. package/dist/stateProvenance.js +97 -0
  44. package/dist/statusline.js +82 -0
  45. package/dist/strictInput.js +52 -0
  46. package/dist/teardown.js +89 -0
  47. package/dist/tmux.js +1295 -0
  48. package/dist/tools/agents.js +1246 -0
  49. package/dist/tools/kv.js +89 -0
  50. package/dist/tools/leases.js +87 -0
  51. package/dist/tools/meta.js +183 -0
  52. package/dist/tools/pads.js +279 -0
  53. package/dist/tools/params.js +14 -0
  54. package/dist/tools/todos.js +382 -0
  55. package/dist/tools/wakes.js +466 -0
  56. package/dist/transcript.js +123 -0
  57. package/dist/version.js +59 -0
  58. package/package.json +62 -0
  59. package/profiles/orchestration/posture.md +27 -0
  60. package/profiles/orchestration/runbook.md +72 -0
  61. package/profiles/orchestration/worker.md +38 -0
  62. package/profiles/simple/posture.md +14 -0
package/dist/hook.js ADDED
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { awaitingFirstPromptSql } from "./firstPrompt.js";
4
+ import { liveBackgroundTasks, SUBAGENT_LATCH_SQL, withholdsIdle } from "./backgroundTasks.js";
5
+ let raw;
6
+ function readRaw() {
7
+ if (raw === undefined) {
8
+ try {
9
+ raw = readFileSync(0, "utf8");
10
+ }
11
+ catch {
12
+ raw = null;
13
+ }
14
+ }
15
+ return raw;
16
+ }
17
+ let payload;
18
+ function readPayload() {
19
+ if (payload === undefined) {
20
+ try {
21
+ payload = JSON.parse(readRaw() ?? "");
22
+ }
23
+ catch {
24
+ payload = {};
25
+ }
26
+ }
27
+ return payload;
28
+ }
29
+ const PAYLOAD_LIMIT = 10_000;
30
+ const UNCHANGED = "unchanged";
31
+ function record(actorId, event, state) {
32
+ try {
33
+ const payload = readRaw() ?? "";
34
+ const stored = payload.length > PAYLOAD_LIMIT
35
+ ? payload.slice(0, PAYLOAD_LIMIT) + `\n[hive: truncated at ${PAYLOAD_LIMIT} bytes]`
36
+ : payload;
37
+ db.prepare("INSERT INTO agent_state_log (actor_id, event, state, payload) VALUES (?, ?, ?, ?)").run(actorId, event, state, stored);
38
+ }
39
+ catch {
40
+ }
41
+ }
42
+ // Only a subagent withholds the latch. A shell or a monitor need never terminate, so latching on one
43
+ // would make a worker that leaves any long-running process never read idle - src/scheduler.ts names
44
+ // them in the standing notice instead (todo 468).
45
+ //
46
+ // Reads whichever channel the payload actually carries, never both: claude's Stop always carries
47
+ // background_tasks, codex's never does. Mechanism and rationale: hive-internals, worker-state.md.
48
+ function waitingOnSubagents(actorId, payload) {
49
+ if (payload.background_tasks !== undefined) {
50
+ return liveBackgroundTasks(payload.background_tasks).some(withholdsIdle);
51
+ }
52
+ return hasOpenSubagent(actorId);
53
+ }
54
+ // Subagents are a SET keyed by agent_id, not a stack of one - reduces every subagent_start/stop row
55
+ // within SUBAGENT_LATCH_MAX_AGE_SECONDS in order, tracking which agent_ids are still unmatched.
56
+ //
57
+ // ACCEPTED, NOT OVERLOOKED: a subagent that genuinely outlives SUBAGENT_LATCH_MAX_AGE_SECONDS falls
58
+ // out of this window, and the worker reads idle while it is still live - a false idle, the exact
59
+ // direction this lane exists to prevent. Deliberate: latching forever is worse, and past this age
60
+ // hive already treats the worker as stall-worthy on its own. Retention evicting a row early cannot
61
+ // produce a NEW failure mode either, only an earlier instance of this same accepted one - see
62
+ // .claude/skills/hive-internals/references/worker-state.md for the residual this does not fully close.
63
+ function hasOpenSubagent(actorId) {
64
+ const rows = db
65
+ .prepare(`SELECT event, payload FROM agent_state_log
66
+ WHERE actor_id = ? AND event IN ('subagent_start', 'subagent_stop')
67
+ AND created_at >= datetime('now', ?)
68
+ ORDER BY id ASC`)
69
+ .all(actorId, SUBAGENT_LATCH_SQL);
70
+ const open = new Set();
71
+ for (const row of rows) {
72
+ let agentId;
73
+ try {
74
+ agentId = JSON.parse(row.payload).agent_id;
75
+ }
76
+ catch {
77
+ continue;
78
+ }
79
+ if (typeof agentId !== "string" || agentId === "")
80
+ continue;
81
+ if (row.event === "subagent_start")
82
+ open.add(agentId);
83
+ else
84
+ open.delete(agentId);
85
+ }
86
+ return open.size > 0;
87
+ }
88
+ // Claude-Code-Notification-only. Proven unreachable for codex, not merely unused: codex's own
89
+ // hooks.json (src/codexHome.ts) never wires an event through the "notify" argv label, and never
90
+ // wires PermissionRequest at all - see test/codex-notify-unreachable.test.mjs, which pins the event
91
+ // list itself so a later lane adding an entry there has to decide this deliberately, not silently.
92
+ function stateForNotification(payload) {
93
+ const type = payload.notification_type;
94
+ const idlePrompt = typeof type === "string"
95
+ ? type === "idle_prompt"
96
+ : /waiting for your input/i.test(String(payload.message ?? ""));
97
+ return idlePrompt ? null : "waiting";
98
+ }
99
+ // Each harness keys its own terminal-reason allowlist (HarnessCapabilities.terminalSessionEndReasons,
100
+ // src/harnesses.ts) rather than sharing one: claude's `clear`, `other`, and an unobserved reason all
101
+ // stop nothing (todo 765; test/fixtures/hook-payloads/README.md), and codex's vocabulary is disjoint
102
+ // from claude's rather than a superset of it (todo 782).
103
+ //
104
+ // ~/.hive/hooks.json is one file per store, shared by every lead and every worker of every project,
105
+ // so this event fires far more often than it acts. HIVE_LEAD is set only in a lead's own pane
106
+ // (src/cli.ts, cmdLead's envFlags), and the project comes from that lead's own row rather than cwd.
107
+ async function stopProcessesForEndedLead(actorId, payload) {
108
+ if (process.env.HIVE_LEAD !== "1")
109
+ return;
110
+ if (typeof payload.reason !== "string")
111
+ return;
112
+ const row = db
113
+ .prepare("SELECT project_id, command FROM agents WHERE actor_id = ? AND kind = 'lead' AND status = 'running'")
114
+ .get(actorId);
115
+ if (!row)
116
+ return;
117
+ // Imported here and nowhere above: harnesses.ts itself pulls tmux at module scope, and every
118
+ // other event in this file runs on every turn of every session without needing any of it.
119
+ const { harnessFor } = await import("./harnesses.js");
120
+ if (!harnessFor(row.command).terminalSessionEndReasons.includes(payload.reason))
121
+ return;
122
+ const { stopAllProcesses, STOP_REASONS } = await import("./processes.js");
123
+ stopAllProcesses(row.project_id, STOP_REASONS.leadSessionEnded);
124
+ }
125
+ function reconcileSessionId(actorId, payload) {
126
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
127
+ if (!sessionId)
128
+ return;
129
+ db.prepare("UPDATE agents SET session_id = ? WHERE actor_id = ? AND kind = 'agent' AND session_id IS NOT ?").run(sessionId, actorId, sessionId);
130
+ }
131
+ // Claude's own payload carries transcript_path too (a directory-resolvable case hive already
132
+ // covers via cwd), so this stores it for whichever harness sends it rather than special-casing
133
+ // codex - the staleness reader is what decides which source to trust per harness (todo 591).
134
+ function reconcileTranscriptPath(actorId, payload) {
135
+ const path = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
136
+ if (!path)
137
+ return;
138
+ db.prepare("UPDATE agents SET transcript_path = ? WHERE actor_id = ? AND kind = 'agent' AND transcript_path IS NOT ?").run(path, actorId, path);
139
+ }
140
+ function stateFor(event, actorId) {
141
+ switch (event) {
142
+ case "prompt":
143
+ return "working";
144
+ case "stop":
145
+ return waitingOnSubagents(actorId, readPayload()) ? "working" : "idle";
146
+ case "notify":
147
+ return stateForNotification(readPayload());
148
+ // Log-only, like idle_prompt's null above: nothing forces a state write when a subagent starts
149
+ // or stops. hasOpenSubagent reads these rows back from agent_state_log at the next "stop" - the
150
+ // record() call below writes the row regardless of what this case returns.
151
+ case "subagent_start":
152
+ case "subagent_stop":
153
+ return null;
154
+ // An event hive has no case for does nothing, rather than asserting a state it doesn't have.
155
+ // Was "waiting" - proven indistinguishable from a real notify payload's own fallback (R4, TEST 3
156
+ // vs TEST 4: a PermissionRequest payload and a wholly unmapped event produced the identical
157
+ // "waiting"). That collision, not argv-vs-payload dispatch, was the actual defect.
158
+ default:
159
+ return null;
160
+ }
161
+ }
162
+ if (process.argv[2] === "post_tool_use") {
163
+ try {
164
+ const { runContextCheckpointHook } = await import("./contextCheckpoint.js");
165
+ runContextCheckpointHook(process.argv[3]);
166
+ }
167
+ catch { }
168
+ process.exit(0);
169
+ }
170
+ const { db } = await import("./db.js");
171
+ try {
172
+ const actorId = process.env.HIVE_AGENT_ID;
173
+ if (actorId) {
174
+ const event = process.argv[2] ?? "";
175
+ const state = stateFor(event, actorId);
176
+ if (state !== null) {
177
+ db.prepare("UPDATE agents SET agent_state = ?, state_changed_at = datetime('now') WHERE actor_id = ? AND kind = 'agent'").run(state, actorId);
178
+ }
179
+ if (event === "prompt") {
180
+ db.prepare(`UPDATE agents SET resumed_at = '' WHERE actor_id = ? AND kind = 'agent' AND ${awaitingFirstPromptSql("agents")}`).run(actorId);
181
+ }
182
+ if (event === "session_end") {
183
+ try {
184
+ await stopProcessesForEndedLead(actorId, readPayload());
185
+ }
186
+ catch {
187
+ }
188
+ }
189
+ reconcileSessionId(actorId, readPayload());
190
+ reconcileTranscriptPath(actorId, readPayload());
191
+ db.prepare("UPDATE actors SET last_seen_at = datetime('now') WHERE id = ?").run(actorId);
192
+ record(actorId, event, state ?? UNCHANGED);
193
+ }
194
+ }
195
+ catch {
196
+ }
197
+ process.exit(0);
package/dist/hooks.js ADDED
@@ -0,0 +1,50 @@
1
+ import { existsSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dataDir, db } from "./db.js";
5
+ import { shellQuote } from "./tmux.js";
6
+ import { effectiveClaudeStatusLine, statusLineEntry } from "./statusline.js";
7
+ // The single builder every generated hooks file wires an event through - reused by ensureHooksFile
8
+ // (claude, one file per store) and ensureCodexHooksFile (codex, one file per worker CODEX_HOME),
9
+ // so the two can never drift into different nesting for the same event name. See
10
+ // .claude/skills/hive-internals/references/tmux-and-panes.md, "the hooks.json schema trap".
11
+ export function hookEntry(event, contextRecord) {
12
+ const hookScript = join(dirname(fileURLToPath(import.meta.url)), "hook.js");
13
+ if (!existsSync(process.execPath)) {
14
+ throw new Error(`hive: this process's own interpreter (${process.execPath}) no longer exists on disk; refusing to register worker-state hooks under a path that would start nothing.`);
15
+ }
16
+ const node = shellQuote(process.execPath);
17
+ return { hooks: [{ type: "command", command: `${node} ${shellQuote(hookScript)} ${event}${contextRecord ? ` ${contextRecord}` : ""}` }] };
18
+ }
19
+ export function ensureHooksFile() {
20
+ const settings = stateHookSettings();
21
+ const path = join(dataDir, "hooks.json");
22
+ writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
23
+ return path;
24
+ }
25
+ function stateHookSettings() {
26
+ return {
27
+ hooks: {
28
+ Stop: [hookEntry("stop")],
29
+ UserPromptSubmit: [hookEntry("prompt")],
30
+ Notification: [hookEntry("notify")],
31
+ SessionEnd: [hookEntry("session_end")],
32
+ },
33
+ };
34
+ }
35
+ export function ensureWorkerHooksFile(agentId, options) {
36
+ const worker = db.prepare("SELECT cwd FROM agents WHERE id = ? AND kind = 'agent'").get(agentId);
37
+ if (!worker)
38
+ throw new Error(`hive: worker ${agentId} does not exist`);
39
+ const settings = {
40
+ ...stateHookSettings(),
41
+ statusLine: statusLineEntry(effectiveClaudeStatusLine(worker.cwd)),
42
+ permissions: { deny: ["SendMessage", "ListAgents"] },
43
+ crossSessionInbound: "refuse",
44
+ };
45
+ if (options.includePostToolUse)
46
+ settings.hooks.PostToolUse = [hookEntry("post_tool_use", "claude")];
47
+ const path = join(dataDir, `worker-${agentId}-hooks.json`);
48
+ writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
49
+ return path;
50
+ }
package/dist/index.js ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { migrate } from "./db.js";
5
+ import { registerAgents } from "./tools/agents.js";
6
+ import { registerMeta } from "./tools/meta.js";
7
+ import { registerPads } from "./tools/pads.js";
8
+ import { registerTodos } from "./tools/todos.js";
9
+ import { registerKv } from "./tools/kv.js";
10
+ import { registerLeases } from "./tools/leases.js";
11
+ import { registerWakes } from "./tools/wakes.js";
12
+ import { registerPrompts } from "./prompts.js";
13
+ import { startScheduler } from "./scheduler.js";
14
+ import { enforceStrictInput } from "./strictInput.js";
15
+ 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.
17
+ Getting started:
18
+ 1. Call whoami to see your actor identity and effective project scope.
19
+ 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.
21
+ 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();
25
+ registerMeta(server);
26
+ registerAgents(server);
27
+ registerPads(server);
28
+ registerTodos(server);
29
+ registerKv(server);
30
+ registerLeases(server);
31
+ registerWakes(server);
32
+ registerPrompts(server);
33
+ startScheduler();
34
+ await server.connect(new StdioServerTransport());
@@ -0,0 +1,191 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, realpathSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { cutToUnitBudget } from "./slug.js";
6
+ export const OUTPUT_BUDGET = 10_000;
7
+ const CONTEXT_BUDGET = 6_000;
8
+ const BOARD_BUDGET = 1_800;
9
+ function currentBranch(dir) {
10
+ try {
11
+ return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
12
+ cwd: dir,
13
+ encoding: "utf8",
14
+ stdio: ["ignore", "pipe", "ignore"],
15
+ }).trim();
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ export function truncate(text, limit) {
22
+ if (text.length <= limit)
23
+ return text;
24
+ return `${cutToUnitBudget(text, limit).trimEnd()}\n[truncated]`;
25
+ }
26
+ async function digest(projectPath, profile, warnings) {
27
+ const { migrate, db } = await import("./db.js");
28
+ const { findProjectForCwd } = await import("./context.js");
29
+ const { ACTIVE_TIMER_WHERE } = await import("./scheduler.js");
30
+ const { getActivePadByName } = await import("./tools/pads.js");
31
+ const { OPEN_BLOCKERS_SQL } = await import("./tools/todos.js");
32
+ migrate();
33
+ const project = findProjectForCwd();
34
+ if (!project || project.path !== projectPath)
35
+ return null;
36
+ const lines = [`[hive] Project "${project.name}" (profile: ${profile}).`];
37
+ for (const w of warnings)
38
+ lines.push(`! hive.yml: ${w}`);
39
+ const headerLines = lines.length;
40
+ const board = getActivePadByName(project.id, "board");
41
+ if (board) {
42
+ lines.push("", "BOARD (top of the pad; pad_read for the rest)", truncate(board.content.trim(), BOARD_BUDGET));
43
+ }
44
+ const inFlight = db
45
+ .prepare("SELECT id, title, status, slug FROM todos WHERE project_id = ? AND status = 'in_progress' AND archived_at IS NULL ORDER BY updated_at DESC LIMIT 10")
46
+ .all(project.id);
47
+ const ready = db
48
+ .prepare(`SELECT id, title, slug FROM todos t
49
+ WHERE t.project_id = ? AND t.status = 'open' AND t.archived_at IS NULL
50
+ AND NOT EXISTS (${OPEN_BLOCKERS_SQL})
51
+ ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, id
52
+ LIMIT 10`)
53
+ .all(project.id);
54
+ const blocked = db
55
+ .prepare(`SELECT COUNT(*) AS n FROM todos t
56
+ WHERE t.project_id = ? AND t.status IN ('open', 'in_progress') AND t.archived_at IS NULL
57
+ AND EXISTS (${OPEN_BLOCKERS_SQL})`)
58
+ .get(project.id).n;
59
+ if (inFlight.length > 0) {
60
+ lines.push("", "IN FLIGHT");
61
+ for (const t of inFlight)
62
+ lines.push(` #${t.id} ${t.slug ? `[${t.slug}] ` : ""}${t.title}`);
63
+ }
64
+ if (ready.length > 0) {
65
+ lines.push("", "READY (unblocked, highest priority first)");
66
+ for (const t of ready)
67
+ lines.push(` #${t.id} ${t.slug ? `[${t.slug}] ` : ""}${t.title}`);
68
+ }
69
+ if (blocked > 0)
70
+ lines.push("", `BLOCKED: ${blocked} todo(s) waiting on a blocker.`);
71
+ const agents = db
72
+ .prepare("SELECT name, actor_id, command, agent_state, state_changed_at, kind, tmux_target, cwd, resumed_at " +
73
+ "FROM agents WHERE project_id = ? AND status = 'running' AND kind = 'agent' ORDER BY id")
74
+ .all(project.id);
75
+ if (agents.length > 0) {
76
+ const { deriveProvenance, describeForHuman } = await import("./stateProvenance.js");
77
+ lines.push("", "WORKERS (per the store, NOT probed; agent_list to confirm they are alive)");
78
+ for (const a of agents) {
79
+ lines.push(` ${a.name} [${describeForHuman(deriveProvenance(a, null))}] ${a.cwd}`);
80
+ }
81
+ }
82
+ const parked = db
83
+ .prepare("SELECT COUNT(*) AS n FROM agents WHERE project_id = ? AND status = 'closed' AND parked_at != ''")
84
+ .get(project.id).n;
85
+ if (parked > 0) {
86
+ lines.push("", `PARKED: ${parked} lane(s) paused rather than finished, waiting to be resumed. ` +
87
+ "`hive status` lists each with its branch and the one call that brings it back.");
88
+ }
89
+ const wakes = db
90
+ .prepare(`SELECT COUNT(*) AS n FROM wakes WHERE project_id = ? AND ${ACTIVE_TIMER_WHERE}`)
91
+ .get(project.id);
92
+ if (wakes.n > 0)
93
+ lines.push("", `WAKE-UPS: ${wakes.n} pending (wake_list for detail).`);
94
+ if (lines.length === headerLines) {
95
+ lines.push("", "The store is empty for this project. Nothing is in flight.");
96
+ }
97
+ lines.push("", "Standing process for this project: run `hive runbook`.");
98
+ return truncate(lines.join("\n"), CONTEXT_BUDGET);
99
+ }
100
+ // Exported so src/cli.ts can hand the identical text to a codex lead as its initial CLI prompt -
101
+ // codex's SessionStart hook rejects the whole payload if this rides inside hookSpecificOutput
102
+ // (see the `forCodex` branch below), so a codex lead gets it a different way, not a different
103
+ // message. Keep the two in sync by construction rather than by two literals staying equal.
104
+ export const TRIAGE_MESSAGE = "Start with morning triage. Run `hive runbook` for this project's standing process, then " +
105
+ "reconcile the state hive just injected against what is really there (agent_list, todo_list, " +
106
+ "wake_list) and report it in a few lines. Propose today's lanes and confirm them with me before " +
107
+ "dispatching anything.";
108
+ export async function evaluate(cwd, opts = {}) {
109
+ if (process.env.HIVE_AGENT_ID && process.env.HIVE_LEAD !== "1") {
110
+ return { fired: false, reason: "worker session (HIVE_AGENT_ID is set)" };
111
+ }
112
+ let dir;
113
+ try {
114
+ dir = realpathSync(cwd);
115
+ }
116
+ catch {
117
+ return { fired: false, reason: "cwd does not exist" };
118
+ }
119
+ if (!existsSync(join(dir, "hive.yml")))
120
+ return { fired: false, reason: "no hive.yml here" };
121
+ const { activeProfile, DEFAULT_LEAD_BRANCHES, loadProjectYml } = await import("./projectYml.js");
122
+ const { profileExists } = await import("./profiles.js");
123
+ const { config, warnings } = loadProjectYml(dir);
124
+ const silent = (reason) => ({ fired: false, reason, warnings });
125
+ const profile = activeProfile(config);
126
+ if (!profile)
127
+ return silent("no profile in hive.yml");
128
+ if (!profileExists(profile))
129
+ return silent(`profile "${profile}" is not on this machine`);
130
+ const branch = currentBranch(dir);
131
+ const leadBranches = config?.lead_branches ?? DEFAULT_LEAD_BRANCHES;
132
+ if (branch != null && !leadBranches.includes(branch)) {
133
+ return silent(`branch "${branch}" is not a lead branch (${leadBranches.join(", ")})`);
134
+ }
135
+ const context = await digest(dir, profile, warnings);
136
+ if (context == null)
137
+ return silent("not a registered hive project root");
138
+ // Codex's SessionStartHookSpecificOutputWire is additionalProperties:false and permits only
139
+ // hookEventName/additionalContext - initialUserMessage inside it fails the whole payload
140
+ // ("SessionStart Failed"), silently dropping the board too. Measured, todo 567 comment 1864.
141
+ const build = (text) => JSON.stringify({
142
+ hookSpecificOutput: {
143
+ hookEventName: "SessionStart",
144
+ additionalContext: text,
145
+ ...(opts.forCodex ? {} : { initialUserMessage: TRIAGE_MESSAGE }),
146
+ },
147
+ });
148
+ let payload = build(context);
149
+ if (payload.length > OUTPUT_BUDGET) {
150
+ const overflow = payload.length - OUTPUT_BUDGET;
151
+ payload = build(truncate(context, Math.max(0, context.length - overflow - 32)));
152
+ }
153
+ return { fired: true, payload, warnings };
154
+ }
155
+ export async function runKickoff(argv = []) {
156
+ const explain = argv.includes("--explain");
157
+ const forCodex = argv.includes("--codex");
158
+ let result;
159
+ try {
160
+ result = await evaluate(process.cwd(), { forCodex });
161
+ }
162
+ catch (e) {
163
+ if (explain)
164
+ console.log(`hive kickoff: silent (${e instanceof Error ? e.message : String(e)}).`);
165
+ return;
166
+ }
167
+ if (explain)
168
+ for (const w of result.warnings ?? [])
169
+ console.log(`! hive.yml: ${w}`);
170
+ if (result.fired && result.payload) {
171
+ process.stdout.write(result.payload);
172
+ if (explain)
173
+ process.stdout.write("\n");
174
+ return;
175
+ }
176
+ if (explain)
177
+ console.log(`hive kickoff: silent (${result.reason}).`);
178
+ }
179
+ const runDirectly = (() => {
180
+ const entry = process.argv[1];
181
+ if (!entry)
182
+ return false;
183
+ try {
184
+ return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ })();
190
+ if (runDirectly)
191
+ await runKickoff(process.argv.slice(2));
@@ -0,0 +1,98 @@
1
+ import { db } from "./db.js";
2
+ import { cutToUnitBudget, flatten } from "./slug.js";
3
+ // 300 sits in the empty band between the two measured populations (todo 475): the largest lead-bound
4
+ // message anyone has argued must arrive whole is 143 bytes, and the smallest real worker report is 440.
5
+ export const LEAD_MESSAGE_THRESHOLD = 300;
6
+ // Budgeted against that same 143, so a message of the class that must arrive whole survives as the head
7
+ // essentially intact. A threshold alone only half-delivers the urgent case: a 450-character message whose
8
+ // first line is "BLOCKED" is exactly what must not become a tool call. Do not trim this to save space.
9
+ const HEAD_BUDGET = 140;
10
+ const ELLIPSIS = "…";
11
+ export const MESSAGE_RETENTION = "-7 days";
12
+ // Bounds the ID RANGE, not the row count, copying LOG_MAX_ROWS's shipped shape: after a time prune leaves
13
+ // a sparse id space it can fire with fewer rows than the name implies, which for a backstop is the safe
14
+ // direction. Far above any observed rate, and lower than agent_state_log's 20,000 because a row here
15
+ // holds unbounded message text rather than a hook payload.
16
+ export const MESSAGE_MAX_ROWS = 5_000;
17
+ function senderName(projectId, actorId) {
18
+ const row = db
19
+ .prepare(`SELECT name FROM agents WHERE project_id = ? AND actor_id = ?
20
+ ORDER BY (status = 'running') DESC, id DESC LIMIT 1`)
21
+ .get(projectId, actorId);
22
+ if (row !== undefined)
23
+ return row.name;
24
+ const actor = db.prepare("SELECT name FROM actors WHERE id = ?").get(actorId);
25
+ return actor?.name?.trim() || actorId;
26
+ }
27
+ export function senderTag(projectId, actorId) {
28
+ const row = db
29
+ .prepare(`SELECT name, kind FROM agents WHERE project_id = ? AND actor_id = ?
30
+ ORDER BY (status = 'running') DESC, id DESC LIMIT 1`)
31
+ .get(projectId, actorId);
32
+ if (row?.kind === "lead")
33
+ return "[hive:lead] ";
34
+ const name = row?.name ?? senderName(projectId, actorId);
35
+ return `[hive:${row ? "worker" : "agent"} ${flatten(name)}] `;
36
+ }
37
+ export function storeLeadMessage(projectId, fromActor, toAgentId, text) {
38
+ const fromName = senderName(projectId, fromActor);
39
+ const { id } = db
40
+ .prepare(`INSERT INTO agent_messages (project_id, from_actor, from_name, to_agent_id, text)
41
+ VALUES (?, ?, ?, ?, ?) RETURNING id`)
42
+ .get(projectId, fromActor, fromName, toAgentId, text);
43
+ return { id, fromName };
44
+ }
45
+ export function readLeadMessage(projectId, messageId) {
46
+ return db
47
+ .prepare(`SELECT id, from_actor, from_name, to_agent_id, text, created_at
48
+ FROM agent_messages WHERE id = ? AND project_id = ?`)
49
+ .get(messageId, projectId);
50
+ }
51
+ export function classifyMiss(messageId) {
52
+ const issued = db.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'agent_messages'").get()?.seq;
53
+ if (issued === undefined || messageId > issued)
54
+ return "never-issued";
55
+ const elsewhere = db.prepare("SELECT 1 AS hit FROM agent_messages WHERE id = ?").get(messageId);
56
+ return elsewhere === undefined ? "pruned" : "other-project";
57
+ }
58
+ export function missMessage(messageId, kind) {
59
+ if (kind === "never-issued") {
60
+ return (`[agent_message_get:never-issued] No message ${messageId} has ever been issued in this store. ` +
61
+ "Nothing was pruned and nothing is being withheld: this id has not been handed out yet, so check " +
62
+ "the id in the pointer line you are reading it from.");
63
+ }
64
+ if (kind === "other-project") {
65
+ return (`[agent_message_get:other-project] Message ${messageId} exists but belongs to a different project, ` +
66
+ "and hive refuses cross-project reads. Read it from a session scoped to that project.");
67
+ }
68
+ return (`[agent_message_get:pruned] Message ${messageId} EXISTED and has since been pruned - this is expiry, ` +
69
+ `not a wrong id and not a scoping refusal. hive keeps agent messages for ${MESSAGE_RETENTION.replace("-", "")} ` +
70
+ `(and at most ${MESSAGE_MAX_ROWS} rows), and the janitor has deleted this one. The pointer line in your ` +
71
+ "scrollback outlives the row it names, by design: the text is gone from the store, so look for what the " +
72
+ "sender wrote to its todo or pad instead, which is the durable copy.");
73
+ }
74
+ // flatten, not slug.ts's stripControlChars: that one is [\x00-\x1F\x7F] and this is \p{Cc}\p{Cf}, which
75
+ // also covers format characters. The head comes from outside, and a raw control byte in it reaches tmux
76
+ // as a keystroke rather than as text, submitting this pointer early and splitting it. The sender's name
77
+ // is flattened by senderTag when it builds the tag ahead of this marker - the tag is who, this is what.
78
+ export function leadPointerMarker(id, text) {
79
+ return `[message #${id}, ${text.length} chars]`;
80
+ }
81
+ export function renderLeadPointer(id, text, tag) {
82
+ const flat = flatten(text);
83
+ const head = flat.length > HEAD_BUDGET ? cutToUnitBudget(flat, HEAD_BUDGET) + ELLIPSIS : flat;
84
+ return `${tag}${leadPointerMarker(id, text)} ${head} agent_message_get(${id}) for the full text.`;
85
+ }
86
+ export function shortenedSendNote(id, deliveredChars) {
87
+ return (`Over ${LEAD_MESSAGE_THRESHOLD} characters to a LEAD, so that pane got a ${deliveredChars}-character ` +
88
+ `pointer instead of this text: a lead's pane is a human's own window and hive keeps it quiet. Nothing ` +
89
+ `was lost - the full text is stored as message ${id} and the pointer names agent_message_get(${id}) - ` +
90
+ `but the lead reads the rest only if it chooses to. Put whatever must be ACTED on in the first ` +
91
+ `${HEAD_BUDGET} characters, or on the todo, where it is durable. Worker-bound sends are never shortened.`);
92
+ }
93
+ export function shortenedSendFailureClause(marker, id) {
94
+ return (` WHAT IS ON THAT SCREEN IS NOT YOUR TEXT. This send was over ${LEAD_MESSAGE_THRESHOLD} characters to a ` +
95
+ `LEAD, so what was pasted is hive's one-line pointer, opening \`${marker}\` - look for THAT line, not for ` +
96
+ `your own words, or you will conclude nothing arrived and resend. Your full text is already stored as ` +
97
+ `message ${id} whatever happens next: agent_message_get(${id}) returns it, so none of it is at risk here.`);
98
+ }