@cmgmyr/hive 1.10.1 → 1.11.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.
@@ -0,0 +1,87 @@
1
+ import { z } from "zod";
2
+ import { currentActor, effectiveProjectId } from "../context.js";
3
+ import { run } from "../result.js";
4
+ import { answerQuestion, askQuestion, getQuestion, listQuestions, updateQuestion, } from "../questions.js";
5
+ import { idParam } from "./params.js";
6
+ const optionParam = z.strictObject({
7
+ label: z.string().describe("Short choice label; distinct within the question."),
8
+ description: z.string().describe("What choosing this means."),
9
+ recommended: z.boolean().describe("Exactly one option is recommended; it is the default."),
10
+ });
11
+ const refParam = z.strictObject({
12
+ kind: z.enum(["todo", "pr"]),
13
+ number: idParam.describe("A todo id or PR number. An identifier, not looked up."),
14
+ });
15
+ const stateParam = z.enum(["answered", "defaulted", "pending", "withdrawn"]);
16
+ const noWrites = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
17
+ const writes = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
18
+ export function registerQuestions(server) {
19
+ server.registerTool("question_ask", {
20
+ description: "Record an open question for the human, keyed to this project and to you as the asker. kind=choice takes 2-4 options (each label, description, recommended; exactly one recommended) and a header of 12 characters or fewer; kind=free_text takes just the question and header. Every question carries default_answer: for a choice it is the recommended option's label. Pass ref (a todo id or PR number) to dedupe: asking again with a ref that already has a PENDING question in this project updates that row instead of adding one. The receipt lists other pending questions in this project that look similar (same normalized text, or at least half their longer words shared), so you can question_update one instead. The record is only stored: put the question to the human yourself, in your own turn. You must be a session an answer can be woken back to. When the question is answered, an ordinary wake carrying the answer is queued to you; read the record with question_get.",
21
+ annotations: writes,
22
+ inputSchema: {
23
+ kind: z.enum(["choice", "free_text"]),
24
+ question: z.string(),
25
+ header: z.string().describe("12 characters or fewer."),
26
+ options: z.array(optionParam).optional().describe("Required for choice, forbidden for free_text."),
27
+ default_answer: z.string().describe("What you do if unanswered. For a choice, the recommended label."),
28
+ ref: refParam.optional(),
29
+ },
30
+ outputSchema: {
31
+ question_id: idParam,
32
+ created: z.boolean(),
33
+ similar_question_ids: z.array(idParam),
34
+ },
35
+ }, (args) => run(() => {
36
+ const projectId = effectiveProjectId();
37
+ const input = {
38
+ kind: args.kind,
39
+ question: args.question,
40
+ header: args.header,
41
+ default_answer: args.default_answer,
42
+ ...(args.options !== undefined ? { options: args.options } : {}),
43
+ ...(args.ref !== undefined ? { ref: args.ref } : {}),
44
+ };
45
+ return askQuestion(projectId, currentActor(), input);
46
+ }));
47
+ server.registerTool("question_get", {
48
+ description: "Read one question in this project by id, in any state, with its answer and notes once answered.",
49
+ annotations: noWrites,
50
+ inputSchema: { question_id: idParam },
51
+ }, (args) => run(() => getQuestion(effectiveProjectId(), args.question_id)));
52
+ server.registerTool("question_list", {
53
+ description: "List this project's pending questions, then its defaulted ones (acted on by default, still open to reversal), oldest first within each group. Answered and withdrawn questions are not listed; read one by id with question_get.",
54
+ annotations: noWrites,
55
+ inputSchema: {},
56
+ }, () => run(() => ({ questions: listQuestions(effectiveProjectId()) })));
57
+ server.registerTool("question_update", {
58
+ description: "Edit a pending question's text (question, header, options, default_answer; the whole result is revalidated) or move it out of pending with state=defaulted (you acted on your default) or state=withdrawn (moot). A defaulted question can still be withdrawn. Moving state needs the asking session or the human; an answered or withdrawn question cannot be changed. The ref, asker and wake target never change.",
59
+ annotations: writes,
60
+ inputSchema: {
61
+ question_id: idParam,
62
+ question: z.string().optional(),
63
+ header: z.string().optional(),
64
+ options: z.array(optionParam).optional(),
65
+ default_answer: z.string().optional(),
66
+ state: z.enum(["defaulted", "withdrawn"]).optional(),
67
+ },
68
+ outputSchema: { question_id: idParam, state: stateParam },
69
+ }, (args) => run(() => {
70
+ const { question_id, ...patch } = args;
71
+ return updateQuestion(effectiveProjectId(), currentActor(), question_id, patch);
72
+ }));
73
+ server.registerTool("question_answer", {
74
+ description: "Record the human's answer to a pending or defaulted question in this project: a picked option label or free text (any nonblank text is accepted, including one outside the offered labels), plus optional notes. The answer commits first. It then queues one wake to the asking session carrying the question, answer and notes verbatim, unless you ARE the asker (you recorded the human's own pick) or the asker cannot be reached now; either way the receipt says wake_id null and why in wake_skipped, and the answer stays readable with question_get. wake_id means queued, not delivered. An answered or withdrawn question refuses with QUESTION_STATE_CONFLICT.",
75
+ annotations: writes,
76
+ inputSchema: {
77
+ question_id: idParam,
78
+ answer: z.string(),
79
+ notes: z.string().optional(),
80
+ },
81
+ outputSchema: {
82
+ question_id: idParam,
83
+ wake_id: idParam.nullable(),
84
+ wake_skipped: z.string().optional(),
85
+ },
86
+ }, (args) => run(() => answerQuestion(effectiveProjectId(), currentActor(), args.question_id, args.answer, args.notes)));
87
+ }
@@ -35,7 +35,7 @@ function refuseUnclassifiableTarget(agent) {
35
35
  "error you can read now. Drive this pane with agent_send(keys: [...]) instead, which is unguarded for " +
36
36
  "exactly this reason.");
37
37
  }
38
- function resolveDelivery(projectId, deliverTo) {
38
+ export function resolveWakeDelivery(projectId, deliverTo) {
39
39
  if (deliverTo != null) {
40
40
  const agent = resolveAgentRef(projectId, deliverTo);
41
41
  // Before the liveness probe: the harness is a durable fact about the row, while liveness is a
@@ -130,7 +130,7 @@ function deliveredByNote(t) {
130
130
  : null;
131
131
  }
132
132
  export const truncateBody = (body) => body.length > 120 ? `${cutToUnitBudget(body, 120)}…` : body;
133
- function rejectUnsafeBody(body) {
133
+ export function assertSafeWakeBody(body) {
134
134
  const bad = findUnsafeControlChar(body, TEXT_ALLOWED_CONTROL_CHARS);
135
135
  if (bad) {
136
136
  throw new Error(`body cannot contain ${bad.label} at offset ${bad.index}: it is typed literally into the target pane ` +
@@ -173,7 +173,7 @@ const openStandingWatch = db.transaction((projectId, owner, body, deliverActor,
173
173
  return row;
174
174
  });
175
175
  function createStandingWatch(projectId, args) {
176
- const delivery = resolveDelivery(projectId, args.deliver_to);
176
+ const delivery = resolveWakeDelivery(projectId, args.deliver_to);
177
177
  const maxWait = args.max_wait_seconds ?? STANDING_WATCH_LIFETIME_SECONDS;
178
178
  const row = openStandingWatch.immediate(projectId, currentActor(), args.body, delivery.actor, delivery.pane, maxWait);
179
179
  const crew = db
@@ -261,7 +261,7 @@ function createLeadWatch(leadProjectId, args) {
261
261
  note: `Project ${leadProjectId}'s lead has already ended its turn; nothing was scheduled. Read it now.`,
262
262
  };
263
263
  }
264
- const delivery = resolveDelivery(home);
264
+ const delivery = resolveWakeDelivery(home);
265
265
  const maxWait = args.max_wait_seconds ?? 900;
266
266
  const row = openLeadWatch.immediate(home, args.body, mode, delivery, maxWait, {
267
267
  projectId: leadProjectId,
@@ -329,9 +329,9 @@ export function registerWakes(server) {
329
329
  repeating: z.boolean(),
330
330
  },
331
331
  }, (args) => run(() => {
332
- rejectUnsafeBody(args.body);
332
+ assertSafeWakeBody(args.body);
333
333
  const projectId = effectiveProjectId(args.project_id);
334
- const delivery = resolveDelivery(projectId, args.deliver_to);
334
+ const delivery = resolveWakeDelivery(projectId, args.deliver_to);
335
335
  return commitQueenStateWrite("wake_set", projectId, args, () => {
336
336
  const row = db
337
337
  .prepare(`INSERT INTO wakes (project_id, owner, body, kind, deliver_actor, deliver_pane, due_at, repeat_every_ms)
@@ -407,7 +407,7 @@ export function registerWakes(server) {
407
407
  note: z.string().optional(),
408
408
  },
409
409
  }, (args) => run(() => {
410
- rejectUnsafeBody(args.body);
410
+ assertSafeWakeBody(args.body);
411
411
  const projectId = effectiveProjectId(args.project_id);
412
412
  const selectors = [
413
413
  args.agents != null && "agents",
@@ -441,7 +441,7 @@ export function registerWakes(server) {
441
441
  "reported as a timeout instead of the failure it actually is. A lead's turn has its own channel, " +
442
442
  "which only the queen may watch, with lead_project_id=<that lead's project id>.");
443
443
  }
444
- const delivery = resolveDelivery(projectId, args.deliver_to);
444
+ const delivery = resolveWakeDelivery(projectId, args.deliver_to);
445
445
  const snapshot = liveTargets();
446
446
  if (mode === "all") {
447
447
  const allIdle = watched.every((a) => {
@@ -577,7 +577,7 @@ export function registerWakes(server) {
577
577
  const sets = [];
578
578
  const params = [];
579
579
  if (args.body != null) {
580
- rejectUnsafeBody(args.body);
580
+ assertSafeWakeBody(args.body);
581
581
  sets.push("body = ?");
582
582
  params.push(args.body);
583
583
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmgmyr/hive",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "description": "Run a crew of Claude Code and Codex workers from one lead session: visible tmux panes, exact state from hooks, one local SQLite store.",
5
5
  "type": "module",
6
6
  "bin": {