@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.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/claude-plugin/.claude-plugin/plugin.json +7 -0
- package/claude-plugin/README.md +47 -0
- package/claude-plugin/hooks/hooks.json +14 -0
- package/claude-plugin/kickoff.mjs +41 -0
- package/claude-plugin/skills/cleanup/SKILL.md +60 -0
- package/dist/abi.js +220 -0
- package/dist/abiProbe.js +9 -0
- package/dist/backgroundTasks.js +51 -0
- package/dist/backup.js +378 -0
- package/dist/brief.js +113 -0
- package/dist/build-info.json +1 -0
- package/dist/claudeDir.js +5 -0
- package/dist/cli.js +2901 -0
- package/dist/codexHome.js +297 -0
- package/dist/config.js +72 -0
- package/dist/context.js +256 -0
- package/dist/contextCheckpoint.js +55 -0
- package/dist/dashboard.js +1158 -0
- package/dist/dataDir.js +89 -0
- package/dist/db.js +421 -0
- package/dist/dispatcher.js +150 -0
- package/dist/firstPrompt.js +6 -0
- package/dist/harnesses.js +173 -0
- package/dist/help.js +353 -0
- package/dist/hook.js +197 -0
- package/dist/hooks.js +50 -0
- package/dist/index.js +34 -0
- package/dist/kickoff.js +191 -0
- package/dist/leadMessage.js +98 -0
- package/dist/mcpConfig.js +121 -0
- package/dist/processes.js +161 -0
- package/dist/profiles.js +278 -0
- package/dist/projectYml.js +254 -0
- package/dist/prompts.js +18 -0
- package/dist/ptys.js +146 -0
- package/dist/result.js +53 -0
- package/dist/scheduler.js +1622 -0
- package/dist/sessionProbe.js +131 -0
- package/dist/slug.js +29 -0
- package/dist/spawn.js +370 -0
- package/dist/stateProvenance.js +97 -0
- package/dist/statusline.js +82 -0
- package/dist/strictInput.js +52 -0
- package/dist/teardown.js +89 -0
- package/dist/tmux.js +1295 -0
- package/dist/tools/agents.js +1246 -0
- package/dist/tools/kv.js +89 -0
- package/dist/tools/leases.js +87 -0
- package/dist/tools/meta.js +183 -0
- package/dist/tools/pads.js +279 -0
- package/dist/tools/params.js +14 -0
- package/dist/tools/todos.js +382 -0
- package/dist/tools/wakes.js +466 -0
- package/dist/transcript.js +123 -0
- package/dist/version.js +59 -0
- package/package.json +62 -0
- package/profiles/orchestration/posture.md +27 -0
- package/profiles/orchestration/runbook.md +72 -0
- package/profiles/orchestration/worker.md +38 -0
- 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());
|
package/dist/kickoff.js
ADDED
|
@@ -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
|
+
}
|