@cmgmyr/hive 1.2.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +6 -1
  2. package/claude-plugin/README.md +4 -1
  3. package/claude-plugin/kickoff.mjs +2 -1
  4. package/claude-plugin/skills/profile/SKILL.md +78 -0
  5. package/claude-plugin/skills/profile/references/interview.md +139 -0
  6. package/claude-plugin/skills/profile/references/recipes/index.md +12 -0
  7. package/claude-plugin/skills/profile/references/recipes/review-decision.md +41 -0
  8. package/claude-plugin/skills/profile/references/recipes/session-continuity.md +37 -0
  9. package/claude-plugin/skills/profile/references/recipes/shared-test-resource.md +45 -0
  10. package/claude-plugin/skills/profile/references/recipes/verification.md +50 -0
  11. package/claude-plugin/skills/profile/references/recipes/worker-context-checkpoint.md +51 -0
  12. package/dist/brief.js +1 -1
  13. package/dist/build-info.json +1 -1
  14. package/dist/cli.js +436 -112
  15. package/dist/codexHome.js +23 -8
  16. package/dist/config.js +8 -61
  17. package/dist/context.js +191 -2
  18. package/dist/db.js +80 -20
  19. package/dist/firstMessage.js +5 -0
  20. package/dist/globalConfig.js +247 -0
  21. package/dist/harnesses.js +14 -4
  22. package/dist/help.js +29 -11
  23. package/dist/hook.js +9 -0
  24. package/dist/hooks.js +2 -1
  25. package/dist/index.js +11 -5
  26. package/dist/kickoff.js +42 -29
  27. package/dist/leadState.js +63 -0
  28. package/dist/leadWatch.js +73 -0
  29. package/dist/portfolio.js +210 -0
  30. package/dist/projectYml.js +242 -178
  31. package/dist/queenAudit.js +118 -0
  32. package/dist/queenDashboard.js +573 -0
  33. package/dist/scheduler.js +190 -20
  34. package/dist/spawn.js +17 -4
  35. package/dist/strictInput.js +24 -7
  36. package/dist/tmux.js +65 -0
  37. package/dist/tools/agents.js +153 -15
  38. package/dist/tools/kv.js +24 -0
  39. package/dist/tools/leases.js +14 -2
  40. package/dist/tools/meta.js +65 -3
  41. package/dist/tools/pads.js +42 -0
  42. package/dist/tools/queenAudit.js +13 -0
  43. package/dist/tools/todos.js +72 -13
  44. package/dist/tools/wakes.js +233 -65
  45. package/package.json +4 -4
  46. package/profiles/orchestration/worker.md +1 -1
  47. package/profiles/queen/posture.md +31 -0
  48. package/profiles/queen/runbook.md +26 -0
@@ -0,0 +1,247 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmodSync, closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { isMap, parse, parseDocument } from "yaml";
5
+ import { storeDir } from "./dataDir.js";
6
+ import { errorMessage } from "./result.js";
7
+ export const ATTACH_MODES = ["auto", "raw", "control"];
8
+ export const AUTO_ATTACH_MODES = ["auto", "on", "off"];
9
+ export function isAttachMode(value) {
10
+ return ATTACH_MODES.includes(value);
11
+ }
12
+ export function isAutoAttach(value) {
13
+ return AUTO_ATTACH_MODES.includes(value);
14
+ }
15
+ export function globalConfigPath() {
16
+ return join(storeDir(), "hive.yml");
17
+ }
18
+ function isMapping(value) {
19
+ if (value === null || typeof value !== "object" || Array.isArray(value))
20
+ return false;
21
+ const prototype = Object.getPrototypeOf(value);
22
+ return prototype === Object.prototype || prototype === null;
23
+ }
24
+ export function isEmptyYamlDocument(text) {
25
+ return text.split(/\r?\n/).every((line) => /^\s*(?:#.*)?$/.test(line));
26
+ }
27
+ function readLayer(path, kind, warnings) {
28
+ let text;
29
+ try {
30
+ text = readFileSync(path, "utf8");
31
+ }
32
+ catch (error) {
33
+ if (error.code !== "ENOENT") {
34
+ warnings.push(`${path}: could not read configuration: ${errorMessage(error)}`);
35
+ }
36
+ return null;
37
+ }
38
+ let value;
39
+ try {
40
+ value = kind === "yaml" ? parse(text) : JSON.parse(text);
41
+ }
42
+ catch (error) {
43
+ warnings.push(`${path}: ${kind === "yaml" ? "hive.yml is not valid YAML" : "config.json is not valid JSON"}: ${errorMessage(error)}`);
44
+ return null;
45
+ }
46
+ if (kind === "yaml" && value == null && isEmptyYamlDocument(text))
47
+ return null;
48
+ if (!isMapping(value)) {
49
+ warnings.push(`${path}: ${kind === "yaml" ? "configuration must be a YAML mapping." : "config.json must be a JSON object."}`);
50
+ return null;
51
+ }
52
+ return value;
53
+ }
54
+ export function readGlobalConfig() {
55
+ const dir = storeDir();
56
+ const warnings = [];
57
+ const root = readLayer(join(dir, "hive.yml"), "yaml", warnings);
58
+ const legacy = readLayer(join(dir, "config.json"), "json", warnings) ?? {};
59
+ return { root, legacy, warnings };
60
+ }
61
+ function readEditableDocument(path, allowMissing) {
62
+ let text;
63
+ try {
64
+ text = readFileSync(path, "utf8");
65
+ }
66
+ catch (error) {
67
+ if (allowMissing && error.code === "ENOENT")
68
+ return parseDocument("");
69
+ throw new Error(`${path}: could not read hive.yml: ${errorMessage(error)}`);
70
+ }
71
+ const document = parseDocument(text);
72
+ if (document.errors.length > 0) {
73
+ throw new Error(`${path}: hive.yml is not valid YAML: ${document.errors.map(errorMessage).join("; ")}`);
74
+ }
75
+ if (!isMap(document.contents))
76
+ throw new Error(`${path}: hive.yml must be a YAML mapping.`);
77
+ return document;
78
+ }
79
+ function writeAtomicYaml(path, text) {
80
+ let mode = 0o600;
81
+ try {
82
+ mode = statSync(path).mode & 0o777;
83
+ }
84
+ catch (error) {
85
+ if (error.code !== "ENOENT")
86
+ throw error;
87
+ }
88
+ const candidate = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
89
+ let temp = null;
90
+ let fd = null;
91
+ try {
92
+ fd = openSync(candidate, "wx", 0o600);
93
+ temp = candidate;
94
+ writeFileSync(fd, text, { encoding: "utf8" });
95
+ closeSync(fd);
96
+ fd = null;
97
+ chmodSync(candidate, mode);
98
+ renameSync(candidate, path);
99
+ temp = null;
100
+ }
101
+ finally {
102
+ if (fd !== null) {
103
+ try {
104
+ closeSync(fd);
105
+ }
106
+ catch {
107
+ }
108
+ }
109
+ if (temp !== null) {
110
+ try {
111
+ unlinkSync(temp);
112
+ }
113
+ catch (error) {
114
+ if (error.code !== "ENOENT")
115
+ throw error;
116
+ }
117
+ }
118
+ }
119
+ }
120
+ function updateGlobalDocument(dir, update) {
121
+ mkdirSync(dir, { recursive: true });
122
+ const path = join(dir, "hive.yml");
123
+ const document = readEditableDocument(path, true);
124
+ if (!update(document))
125
+ return false;
126
+ let text;
127
+ try {
128
+ text = document.toString();
129
+ }
130
+ catch (error) {
131
+ throw new Error(`${path}: could not serialize hive.yml: ${errorMessage(error)}`);
132
+ }
133
+ try {
134
+ writeAtomicYaml(path, text);
135
+ }
136
+ catch (error) {
137
+ throw new Error(`${path}: could not write hive.yml: ${errorMessage(error)}`);
138
+ }
139
+ return true;
140
+ }
141
+ export function writeGlobalConfigKey(key, value) {
142
+ const dir = storeDir();
143
+ updateGlobalDocument(dir, (document) => {
144
+ document.set(key, value);
145
+ return true;
146
+ });
147
+ }
148
+ export function migrateLegacyConfig() {
149
+ const dir = storeDir();
150
+ const legacyPath = join(dir, "config.json");
151
+ const archivePath = `${legacyPath}.migrated`;
152
+ const globalPath = join(dir, "hive.yml");
153
+ const refusal = (path, warning) => ({
154
+ migrated: false,
155
+ notice: null,
156
+ warnings: [`${path}: ${warning}`],
157
+ });
158
+ let legacyText;
159
+ try {
160
+ legacyText = readFileSync(legacyPath, "utf8");
161
+ }
162
+ catch (error) {
163
+ if (error.code === "ENOENT")
164
+ return { migrated: false, notice: null, warnings: [] };
165
+ return refusal(legacyPath, `could not read config.json: ${errorMessage(error)}`);
166
+ }
167
+ try {
168
+ lstatSync(archivePath);
169
+ return refusal(legacyPath, `archive ${archivePath} already exists; refusing to overwrite it.`);
170
+ }
171
+ catch (error) {
172
+ if (error.code !== "ENOENT") {
173
+ return refusal(archivePath, `could not check migration archive: ${errorMessage(error)}`);
174
+ }
175
+ }
176
+ let legacy;
177
+ try {
178
+ const raw = JSON.parse(legacyText);
179
+ if (!isMapping(raw))
180
+ return refusal(legacyPath, "config.json must be a JSON object.");
181
+ legacy = raw;
182
+ }
183
+ catch (error) {
184
+ return refusal(legacyPath, `config.json is not valid JSON: ${errorMessage(error)}`);
185
+ }
186
+ try {
187
+ updateGlobalDocument(dir, (document) => {
188
+ let changed = false;
189
+ for (const [key, value] of Object.entries(legacy)) {
190
+ if (key === "review_tags" || document.has(key))
191
+ continue;
192
+ document.set(key, value);
193
+ changed = true;
194
+ }
195
+ return changed;
196
+ });
197
+ }
198
+ catch (error) {
199
+ const message = errorMessage(error);
200
+ const warning = message.startsWith(`${globalPath}:`)
201
+ ? message
202
+ : `${globalPath}: could not update hive.yml: ${message}`;
203
+ return { migrated: false, notice: null, warnings: [warning] };
204
+ }
205
+ try {
206
+ renameSync(legacyPath, archivePath);
207
+ }
208
+ catch (error) {
209
+ return refusal(legacyPath, `could not archive config.json as ${archivePath}: ${errorMessage(error)}`);
210
+ }
211
+ return {
212
+ migrated: true,
213
+ notice: `hive: migrated ${legacyPath} into ${globalPath}; original saved as ${archivePath}.`,
214
+ warnings: [],
215
+ };
216
+ }
217
+ export function resolveAttachSetting(key, global, legacy, warnings) {
218
+ const mode = key === "attach";
219
+ const env = mode
220
+ ? isAttachMode(process.env.HIVE_ATTACH_MODE) ? process.env.HIVE_ATTACH_MODE : null
221
+ : process.env.HIVE_AUTO_ATTACH === "0"
222
+ ? "off"
223
+ : isAutoAttach(process.env.HIVE_AUTO_ATTACH) ? process.env.HIVE_AUTO_ATTACH : null;
224
+ if (env != null)
225
+ return mode ? { mode: env, source: "env" } : { value: env, source: "env" };
226
+ const path = globalConfigPath();
227
+ if (global && Object.hasOwn(global, key)) {
228
+ const value = global[key];
229
+ if (value == null)
230
+ return mode ? { mode: "auto", source: "global" } : { value: "auto", source: "global" };
231
+ if (mode ? isAttachMode(value) : isAutoAttach(value)) {
232
+ return mode ? { mode: value, source: "global" } : { value: value, source: "global" };
233
+ }
234
+ warnings.push(`${path}: ${key} must be one of ${(mode ? ["auto", "raw", "control"] : ["auto", "on", "off"]).join(", ")}; ignoring "${String(value)}".`);
235
+ return mode ? { mode: "auto", source: "built-in" } : { value: "auto", source: "built-in" };
236
+ }
237
+ if (Object.hasOwn(legacy, key)) {
238
+ const value = legacy[key];
239
+ if (value == null)
240
+ return mode ? { mode: "auto", source: "global" } : { value: "auto", source: "global" };
241
+ if (mode ? isAttachMode(value) : isAutoAttach(value)) {
242
+ return mode ? { mode: value, source: "global" } : { value: value, source: "global" };
243
+ }
244
+ warnings.push(`${path.replace(/hive\.yml$/, "config.json")}: ${key} has an invalid value; ignoring it.`);
245
+ }
246
+ return mode ? { mode: "auto", source: "built-in" } : { value: "auto", source: "built-in" };
247
+ }
package/dist/harnesses.js CHANGED
@@ -1,4 +1,4 @@
1
- import { codexInputBoxState, codexPaneChoiceCheck, codexPaneHasInputBox, inputBoxState, paneChoiceCheck, paneHasInputBox, } from "./tmux.js";
1
+ import { claudeTrailerView, codexInputBoxState, codexTrailerView, codexPaneChoiceCheck, codexPaneHasInputBox, inputBoxState, paneChoiceCheck, paneHasInputBox, } from "./tmux.js";
2
2
  export function commandHead(command) {
3
3
  return command.trim().split(/\s+/)[0] ?? "";
4
4
  }
@@ -52,10 +52,11 @@ const claudeHarness = {
52
52
  supportsResume: true,
53
53
  supportsRename: true,
54
54
  classifiesPaneScreen: true,
55
- paneClassifier: { choiceCheck: paneChoiceCheck, inputBoxState, hasInputBox: paneHasInputBox },
55
+ paneClassifier: { choiceCheck: paneChoiceCheck, inputBoxState, hasInputBox: paneHasInputBox, trailerView: claudeTrailerView },
56
56
  hasScopes: true,
57
57
  needsHome: false,
58
- initialPromptArgs: null,
58
+ // "--" so a message that starts with a dash is never read as a flag.
59
+ initialPromptArgs: (message) => ["--", message],
59
60
  // clear, other, and an unobserved value all stop nothing (todo 765; test/fixtures/hook-payloads/
60
61
  // README.md). Only these two are what Claude Code documents as the session being over for good.
61
62
  terminalSessionEndReasons: ["prompt_input_exit", "logout"],
@@ -79,11 +80,12 @@ export const codexHarness = {
79
80
  choiceCheck: codexPaneChoiceCheck,
80
81
  inputBoxState: codexInputBoxState,
81
82
  hasInputBox: codexPaneHasInputBox,
83
+ trailerView: codexTrailerView,
82
84
  },
83
85
  hasScopes: false,
84
86
  needsHome: true,
85
87
  // Auto-submits with no Enter; verified live on v0.149.0.
86
- initialPromptArgs: (message) => [message],
88
+ initialPromptArgs: (message) => ["--", message],
87
89
  // Measured live on 0.151.0 (todo 782 S1): both `/quit` and Ctrl-C twice fire SessionEnd with
88
90
  // reason "other" - never claude's `prompt_input_exit` or `logout`. Codex's vocabulary is
89
91
  // disjoint from claude's, not a superset of it, so it gets its own set rather than joining
@@ -171,3 +173,11 @@ export function transcriptDirFor(command) {
171
173
  export function hasTranscriptSignal(row) {
172
174
  return transcriptDirFor(row.command) || row.transcript_path !== "";
173
175
  }
176
+ export function readOnlyExtraArgsAllowed(harness, args = []) {
177
+ const allowlist = {
178
+ codex: { flag: "-c", value: /^model_reasoning_effort=(low|medium|high|xhigh)$/ },
179
+ claude: { flag: "--effort", value: /^(low|medium|high|xhigh|max)$/ },
180
+ };
181
+ const allowed = allowlist[harness];
182
+ return allowed != null && args.length % 2 === 0 && args.every((arg, index) => index % 2 === 0 ? arg === allowed.flag : allowed.value.test(arg));
183
+ }
package/dist/help.js CHANGED
@@ -37,7 +37,7 @@ TODOS — shared task tracking with dependencies
37
37
  KV — small shared status values (with optional TTL)
38
38
  kv_set, kv_get, kv_list, kv_delete
39
39
 
40
- LEASES — advisory expiring claims on shared work areas
40
+ LEASES — advisory expiring claims on shared state
41
41
  lease_acquire, lease_release
42
42
 
43
43
  PROJECTS — scope management
@@ -97,8 +97,10 @@ Rules that keep this sane:
97
97
  - Keep worker prompts self-contained: include pad and todo ids.
98
98
  - Record every decision in the pad or a todo comment. Sessions die;
99
99
  the store survives.
100
- - Take a lease (lease_acquire) before editing a shared file area; leases
101
- expire so a dead session never wedges anyone.`,
100
+ - Take a lease (lease_acquire) before changing state another session can
101
+ also change: a checkout two sessions share, a dev database, a port. Files
102
+ in a directory only you work in, such as your own git worktree, need
103
+ none. Leases expire so a dead session never wedges anyone.`,
102
104
  profiles: `PROFILES — standing instructions shared across projects
103
105
 
104
106
  A profile is a named set of instructions this project runs under, named by
@@ -126,7 +128,7 @@ If the human asks to change how work runs here, that is an edit to the
126
128
  runbook (fork it first), not a pad write.`,
127
129
  agents: `AGENTS — spawn and drive worker sessions in tmux
128
130
 
129
- agent_spawn(name?, model?, command?, extra_args?, cwd?, placement?, layout?) —
131
+ agent_spawn(name?, model?, command?, extra_args?, read_only?, cwd?, placement?, layout?) —
130
132
  start a worker (default command: claude) in session hive-main.
131
133
  placement="split" (default) tiles the worker as a pane in the lead's
132
134
  window so the whole crew shares one screen; placement="window" gives it
@@ -195,8 +197,9 @@ its turn), waiting (needs permission or input). Non-claude commands show
195
197
 
196
198
  wake_set(delay_seconds, body, deliver_to?, repeat_every_seconds?) —
197
199
  one-shot or repeating wake-up
198
- wake_when_idle(agents | scope, body, mode?, max_wait_seconds?,
199
- deliver_to?) — fire when workers go idle. Pass exactly one of:
200
+ wake_when_idle(agents | scope | lead_project_id, body, mode?,
201
+ max_wait_seconds?, deliver_to?) — fire when workers go idle. Pass
202
+ exactly one of:
200
203
  scope="project" — a STANDING watch over the crew you spawn in this project.
201
204
  It reports EACH worker as it finishes or its window dies, covers
202
205
  workers spawned after you set it, and keeps watching until
@@ -209,6 +212,11 @@ its turn), waiting (needs permission or input). Non-claude commands show
209
212
  others; mode=all fires when every watched agent is idle and returns
210
213
  already_satisfied instead of scheduling if they already are.
211
214
  Agents already idle when it was set do not count.
215
+ lead_project_id=<id> — QUEEN ONLY: a ONE-SHOT on another project's
216
+ running lead ENDING A TURN (not finishing its work). Stored in and
217
+ delivered to the queen's own project; refuses project_id and
218
+ deliver_to. A dead, reissued or restarted lead ends it with that
219
+ reason instead.
212
220
  wake_get(wake_id) — read one wake-up by id, with its untruncated body
213
221
  (wake_list truncates at 120 chars)
214
222
  wake_update(wake_id, delay_seconds?, body?, repeat_every_seconds?) — edit
@@ -246,7 +254,8 @@ Every pad, todo, kv entry, and lease belongs to one project (a directory).
246
254
  project_select(project_id) — set this session's default scope
247
255
  project_prune() — delete every registered project that owns no rows
248
256
  anywhere in the store, verified individually; never your own; refuses
249
- under HIVE_PROJECT_LOCK=1 since it sweeps every project, not just yours
257
+ under HIVE_PROJECT_LOCK=1 since it sweeps every project, not just yours.
258
+ With project_id, removes only that project, and only if it owns no rows
250
259
 
251
260
  Resolution order: explicit project_id argument, then session selection, then
252
261
  working-directory auto-detection. When the working directory matches no
@@ -262,7 +271,14 @@ Cross-project access is intentional-only. An empty result means there is
262
271
  nothing in this project; report that and stop. Reach into another project
263
272
  (project_select or a project_id override) only when the user explicitly
264
273
  names it. Set HIVE_PROJECT_LOCK=1 in a session's environment to reject
265
- cross-project access entirely; use it for worker sessions.`,
274
+ cross-project access entirely; use it for worker sessions.
275
+
276
+ The one standing exception is the queen: the running lead of <data dir>/queen,
277
+ started by \`hive queen\`. It may read any registered project by project_id.
278
+ Into another project it may write only todo_create and todo_comment, plus
279
+ wake_set, wake_when_idle, wake_update, wake_cancel and agent_send text
280
+ addressed to that project's running lead. Every other write there refuses
281
+ with QUEEN_CROSS_PROJECT_WRITE_REFUSED. Workers the queen spawns stay locked.`,
266
282
  identity: `IDENTITY — who is writing
267
283
 
268
284
  Every write records an actor. Identity comes from environment variables read
@@ -343,13 +359,15 @@ todo_list without marking anything done.`,
343
359
 
344
360
  Use kv for values another session needs to discover: current branch, a port,
345
361
  a phase flag. Use pads for anything longer than a line or two.`,
346
- leases: `LEASES — advisory expiring claims on shared work areas
362
+ leases: `LEASES — advisory expiring claims on shared state
347
363
 
348
364
  lease_acquire(key, ttl_seconds) — non-blocking; returns the holder if
349
365
  already taken; re-taking your own lease extends it
350
366
  lease_release(key)
351
367
 
352
368
  Keys are project-scoped and free-form; keep them stable and specific, like
353
- "file:src/api/routes.ts" or "todo:42". Leases expire on their own, so a
354
- crashed session never wedges the team.`,
369
+ "checkout:main", "db:dev", "port:3000" or "todo:42". A lease tells other
370
+ sessions the state is in use; it enforces nothing, and it cannot prevent a
371
+ merge conflict between two branches. Leases expire on their own, so a crashed
372
+ session never wedges the team.`,
355
373
  };
package/dist/hook.js CHANGED
@@ -179,6 +179,15 @@ try {
179
179
  if (event === "prompt") {
180
180
  db.prepare(`UPDATE agents SET resumed_at = '' WHERE actor_id = ? AND kind = 'agent' AND ${awaitingFirstPromptSql("agents")}`).run(actorId);
181
181
  }
182
+ // A lead's turn state is its own table; agents.agent_state stays worker-only.
183
+ if (process.env.HIVE_LEAD === "1" && (event === "prompt" || event === "stop" || event === "session_end")) {
184
+ try {
185
+ const { applyLeadHook } = await import("./leadState.js");
186
+ applyLeadHook(actorId, event, readPayload(), () => waitingOnSubagents(actorId, readPayload()));
187
+ }
188
+ catch {
189
+ }
190
+ }
182
191
  if (event === "session_end") {
183
192
  try {
184
193
  await stopProcessesForEndedLead(actorId, readPayload());
package/dist/hooks.js CHANGED
@@ -39,7 +39,8 @@ export function ensureWorkerHooksFile(agentId, options) {
39
39
  const settings = {
40
40
  ...stateHookSettings(),
41
41
  statusLine: statusLineEntry(effectiveClaudeStatusLine(worker.cwd)),
42
- permissions: { deny: ["SendMessage", "ListAgents"] },
42
+ permissions: { deny: ["SendMessage", "ListAgents", ...(options.readOnly ? ["Edit", "Write", "NotebookEdit"] : [])] },
43
+ ...(options.readOnly ? { sandbox: { enabled: true, failIfUnavailable: true, autoAllowBashIfSandboxed: true, allowUnsandboxedCommands: false, filesystem: { denyWrite: ["//"] } } } : {}),
43
44
  crossSessionInbound: "refuse",
44
45
  };
45
46
  if (options.includePostToolUse)
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { migrate } from "./db.js";
5
+ import { assertQueenToolCall, isQueenLead } from "./context.js";
5
6
  import { registerAgents } from "./tools/agents.js";
6
7
  import { registerMeta } from "./tools/meta.js";
7
8
  import { registerPads } from "./tools/pads.js";
@@ -9,19 +10,23 @@ import { registerTodos } from "./tools/todos.js";
9
10
  import { registerKv } from "./tools/kv.js";
10
11
  import { registerLeases } from "./tools/leases.js";
11
12
  import { registerWakes } from "./tools/wakes.js";
13
+ import { registerQueenAudit } from "./tools/queenAudit.js";
12
14
  import { registerPrompts } from "./prompts.js";
13
15
  import { schedulerIntervalMs, startScheduler } from "./scheduler.js";
14
16
  import { enforceStrictInput } from "./strictInput.js";
17
+ migrate();
18
+ const SCOPE_DISCIPLINE = isQueenLead()
19
+ ? "Scope discipline: you are the queen, the lead of hive's queen home. Your own project is the working directory's. You may read any registered project by passing its project_id. Into another project you may write only todo_create and todo_comment, plus wakes and agent_send text addressed to that project's running lead; every other write there refuses with QUEEN_CROSS_PROJECT_WRITE_REFUSED. See help(topic=\"projects\")."
20
+ : "Scope discipline: all state is project-scoped, and the current project is the working directory's. An empty result means there is nothing IN THIS PROJECT; report that and stop. Never browse other projects' state (project_id overrides, project_select) unless the user explicitly names another project and asks for it.";
15
21
  const server = enforceStrictInput(new McpServer({ name: "hive", version: "0.1.0" }, {
16
- instructions: `Hive gives Claude Code sessions shared, persistent state for coordinating work on a project: pads for plans and findings, todos for tracked work with blockers, kv for small shared values, leases for claiming shared work areas, agents for spawning and directing other sessions, and wake-ups for scheduled or idle-triggered check-ins. Search for these tools when a task needs to persist past this conversation, hand off to or watch another session, or track multi-step work with dependencies.
22
+ instructions: `Hive gives Claude Code sessions shared, persistent state for coordinating work on a project: pads for plans and findings, todos for tracked work with blockers, kv for small shared values, leases for claiming shared state, agents for spawning and directing other sessions, and wake-ups for scheduled or idle-triggered check-ins. Search for these tools when a task needs to persist past this conversation, hand off to or watch another session, or track multi-step work with dependencies.
17
23
  Getting started:
18
24
  1. Call whoami to see your actor identity and effective project scope.
19
25
  2. Call help for an overview, or help(topic="workflow") for the lead/worker playbook.
20
- 3. Pads hold shared plans and findings. Todos coordinate work with blockers and comments. KV holds small shared values; leases claim shared work areas and expire on their own.
26
+ 3. Pads hold shared plans and findings. Todos coordinate work with blockers and comments. KV holds small shared values; leases claim state more than one session can change and expire on their own.
21
27
  4. Before triaging or orchestrating work, read the standing process: run \`hive runbook\` in a shell. It prints the project's profile runbook, or its runbook pad when the project has no profile. Do not read the pad first; most projects with a profile have no runbook pad at all.
22
- Scope discipline: all state is project-scoped, and the current project is the working directory's. An empty result means there is nothing IN THIS PROJECT; report that and stop. Never browse other projects' state (project_id overrides, project_select) unless the user explicitly names another project and asks for it.`,
23
- }));
24
- migrate();
28
+ ${SCOPE_DISCIPLINE}`,
29
+ }), assertQueenToolCall);
25
30
  registerMeta(server);
26
31
  registerAgents(server);
27
32
  registerPads(server);
@@ -29,6 +34,7 @@ registerTodos(server);
29
34
  registerKv(server);
30
35
  registerLeases(server);
31
36
  registerWakes(server);
37
+ registerQueenAudit(server);
32
38
  registerPrompts(server);
33
39
  startScheduler(schedulerIntervalMs());
34
40
  await server.connect(new StdioServerTransport());
package/dist/kickoff.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, realpathSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { realpathSync } from "node:fs";
4
3
  import { fileURLToPath } from "node:url";
5
4
  import { cutToUnitBudget } from "./slug.js";
5
+ import { FIRST_MESSAGE_SHA_ENV } from "./firstMessage.js";
6
6
  export const OUTPUT_BUDGET = 10_000;
7
7
  const CONTEXT_BUDGET = 6_000;
8
8
  const BOARD_BUDGET = 1_800;
@@ -97,14 +97,26 @@ async function digest(projectPath, profile, warnings) {
97
97
  lines.push("", "Standing process for this project: run `hive runbook`.");
98
98
  return truncate(lines.join("\n"), CONTEXT_BUDGET);
99
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.";
100
+ // Shared with `hive lead`, which passes the first message on the command line only when this holds.
101
+ export async function kickoffGate(dir) {
102
+ const { activeProfile, DEFAULT_LEAD_BRANCHES, loadProjectYml } = await import("./projectYml.js");
103
+ const { profileExists } = await import("./profiles.js");
104
+ const { config, warnings, sources } = loadProjectYml(dir);
105
+ const no = (reason) => ({ ok: false, reason, warnings });
106
+ if (config == null)
107
+ return no("no hive.yml here");
108
+ const profile = activeProfile(config);
109
+ if (!profile)
110
+ return no("no profile in hive.yml");
111
+ if (!profileExists(profile))
112
+ return no(`profile "${profile}" is not on this machine`);
113
+ const branch = currentBranch(dir);
114
+ const leadBranches = config.lead_branches ?? DEFAULT_LEAD_BRANCHES;
115
+ if (branch != null && !leadBranches.includes(branch)) {
116
+ return no(`branch "${branch}" is not a lead branch (${leadBranches.join(", ")})`);
117
+ }
118
+ return { ok: true, config, warnings, sources, profile };
119
+ }
108
120
  export async function evaluate(cwd, opts = {}) {
109
121
  if (process.env.HIVE_AGENT_ID && process.env.HIVE_LEAD !== "1") {
110
122
  return { fired: false, reason: "worker session (HIVE_AGENT_ID is set)" };
@@ -116,33 +128,25 @@ export async function evaluate(cwd, opts = {}) {
116
128
  catch {
117
129
  return { fired: false, reason: "cwd does not exist" };
118
130
  }
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
- }
131
+ const gate = await kickoffGate(dir);
132
+ if (!gate.ok)
133
+ return { fired: false, reason: gate.reason, warnings: gate.warnings };
134
+ const { config, warnings, sources, profile } = gate;
135
135
  const context = await digest(dir, profile, warnings);
136
136
  if (context == null)
137
- return silent("not a registered hive project root");
137
+ return { fired: false, reason: "not a registered hive project root", warnings };
138
138
  // Codex's SessionStartHookSpecificOutputWire is additionalProperties:false and permits only
139
139
  // hookEventName/additionalContext - initialUserMessage inside it fails the whole payload
140
140
  // ("SessionStart Failed"), silently dropping the board too. Measured, todo 567 comment 1864.
141
+ const { resolveFirstMessage } = await import("./projectYml.js");
142
+ const first = resolveFirstMessage(config, sources);
143
+ const onCommandLine = process.env.HIVE_LEAD === "1" && !!process.env[FIRST_MESSAGE_SHA_ENV];
144
+ const hookMessage = opts.forCodex || onCommandLine || first.message === "" ? null : first.message;
141
145
  const build = (text) => JSON.stringify({
142
146
  hookSpecificOutput: {
143
147
  hookEventName: "SessionStart",
144
148
  additionalContext: text,
145
- ...(opts.forCodex ? {} : { initialUserMessage: TRIAGE_MESSAGE }),
149
+ ...(hookMessage === null ? {} : { initialUserMessage: hookMessage }),
146
150
  },
147
151
  });
148
152
  let payload = build(context);
@@ -150,7 +154,7 @@ export async function evaluate(cwd, opts = {}) {
150
154
  const overflow = payload.length - OUTPUT_BUDGET;
151
155
  payload = build(truncate(context, Math.max(0, context.length - overflow - 32)));
152
156
  }
153
- return { fired: true, payload, warnings };
157
+ return { fired: true, payload, warnings, firstMessage: first };
154
158
  }
155
159
  export async function runKickoff(argv = []) {
156
160
  const explain = argv.includes("--explain");
@@ -168,6 +172,15 @@ export async function runKickoff(argv = []) {
168
172
  for (const w of result.warnings ?? [])
169
173
  console.log(`! hive.yml: ${w}`);
170
174
  if (result.fired && result.payload) {
175
+ if (explain && result.firstMessage) {
176
+ const { message, source } = result.firstMessage;
177
+ if (source === "unset")
178
+ console.log("first message: none (first_message is not set)");
179
+ else if (source === "empty")
180
+ console.log("first message: none (empty string in hive.yml)");
181
+ else
182
+ console.log(`first message (${source}): ${message}`);
183
+ }
171
184
  process.stdout.write(result.payload);
172
185
  if (explain)
173
186
  process.stdout.write("\n");
@@ -0,0 +1,63 @@
1
+ import { db } from "./db.js";
2
+ import { FIRST_MESSAGE_SHA_ENV, firstMessageDigest } from "./firstMessage.js";
3
+ export function readLeadTurnState(agentId) {
4
+ return db.prepare("SELECT * FROM lead_turn_state WHERE agent_id = ?").get(agentId) ?? null;
5
+ }
6
+ function isHiveFirstMessage(payload) {
7
+ if (typeof payload.prompt !== "string")
8
+ return false;
9
+ const prompt = payload.prompt.trim();
10
+ const launched = process.env[FIRST_MESSAGE_SHA_ENV];
11
+ return !!launched && firstMessageDigest(prompt) === launched;
12
+ }
13
+ // "idle" means the lead's turn ended after a prompt hive did not type itself, in this pane launch
14
+ // and this session. It never means the lead finished all its work.
15
+ export function nextLeadTurn(cur, panePid, event, payload, subagentsLive) {
16
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
17
+ if (sessionId === "")
18
+ return null;
19
+ const sameEpoch = cur !== null && cur.pane_pid === panePid && cur.session_id === sessionId;
20
+ const base = sameEpoch
21
+ ? { pane_pid: panePid, session_id: sessionId, state: cur.state, idle_seq: cur.idle_seq }
22
+ : { pane_pid: panePid, session_id: sessionId, state: "unknown", idle_seq: cur?.idle_seq ?? 0 };
23
+ switch (event) {
24
+ case "prompt":
25
+ return isHiveFirstMessage(payload) ? base : { ...base, state: "working" };
26
+ case "stop":
27
+ if (base.state !== "working" || subagentsLive())
28
+ return base;
29
+ return { ...base, state: "idle", idle_seq: base.idle_seq + 1 };
30
+ case "session_end":
31
+ if (cur === null || cur.session_id !== sessionId)
32
+ return null;
33
+ return { pane_pid: cur.pane_pid, session_id: sessionId, state: "unknown", idle_seq: cur.idle_seq };
34
+ default:
35
+ return null;
36
+ }
37
+ }
38
+ const applyInTransaction = db.transaction((agentId, panePid, event, payload, subagentsLive) => {
39
+ const cur = readLeadTurnState(agentId);
40
+ const next = nextLeadTurn(cur, panePid, event, payload, subagentsLive);
41
+ if (next === null)
42
+ return;
43
+ if (cur !== null &&
44
+ cur.pane_pid === next.pane_pid &&
45
+ cur.session_id === next.session_id &&
46
+ cur.state === next.state &&
47
+ cur.idle_seq === next.idle_seq) {
48
+ return;
49
+ }
50
+ db.prepare(`INSERT INTO lead_turn_state (agent_id, pane_pid, session_id, state, idle_seq, last_event, changed_at)
51
+ VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', 'now'))
52
+ ON CONFLICT(agent_id) DO UPDATE SET pane_pid = excluded.pane_pid, session_id = excluded.session_id,
53
+ state = excluded.state, idle_seq = excluded.idle_seq, last_event = excluded.last_event,
54
+ changed_at = excluded.changed_at`).run(agentId, next.pane_pid, next.session_id, next.state, next.idle_seq, event);
55
+ });
56
+ export function applyLeadHook(actorId, event, payload, subagentsLive) {
57
+ const lead = db
58
+ .prepare("SELECT id, pane_pid FROM agents WHERE actor_id = ? AND kind = 'lead' AND status = 'running' ORDER BY id DESC LIMIT 1")
59
+ .get(actorId);
60
+ if (!lead)
61
+ return;
62
+ applyInTransaction.immediate(lead.id, lead.pane_pid, event, payload, subagentsLive);
63
+ }