@cmgmyr/hive 1.10.1 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/leadState.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { liveBackgroundTasks } from "./backgroundTasks.js";
1
2
  import { db } from "./db.js";
2
3
  import { FIRST_MESSAGE_SHA_ENV, firstMessageDigest } from "./firstMessage.js";
4
+ import { postponeGrace, readActiveHandoff } from "./leadHandoffState.js";
3
5
  export function readLeadTurnState(agentId) {
4
6
  return db.prepare("SELECT * FROM lead_turn_state WHERE agent_id = ?").get(agentId) ?? null;
5
7
  }
@@ -9,7 +11,23 @@ export function leadSessionEnded(row, ownership) {
9
11
  const turn = readLeadTurnState(row.id);
10
12
  return turn?.last_event === "session_end" && turn.pane_pid === row.pane_pid ? { ended_at: turn.changed_at } : null;
11
13
  }
12
- function isHiveFirstMessage(payload) {
14
+ // The hive-authored prompt shapes conversationHoldsWake excludes, as SQL over an agent_state_log
15
+ // payload column. isHumanPrompt below is its JS twin; a parity test keeps them in step.
16
+ export function humanPromptSql(col) {
17
+ const p = `CASE WHEN json_valid(${col}) THEN COALESCE(json_extract(${col}, '$.prompt'), ${col}) ELSE ${col} END`;
18
+ return (`${p} NOT LIKE '%[hive wake #%' AND ${p} NOT LIKE '[hive:%' AND ${p} NOT LIKE '<task-notification>%' ` +
19
+ `AND ${p} NOT LIKE '<cross-session-message from="%" from-name="hive">' || char(10) || '[hive:worker %] [message #%,%' || char(10) || '</cross-session-message>%'`);
20
+ }
21
+ // Includes SQLite LIKE's ASCII case-insensitivity.
22
+ export function isHumanPrompt(prompt) {
23
+ const p = prompt.toLowerCase();
24
+ if (p.includes("[hive wake #") || p.startsWith("[hive:") || p.startsWith("<task-notification>"))
25
+ return false;
26
+ return !/^<cross-session-message from="[\s\S]*" from-name="hive">\n\[hive:worker [\s\S]*\] \[message #[\s\S]*,[\s\S]*\n<\/cross-session-message>/.test(p);
27
+ }
28
+ // The agent_state_log state label on hive's own first message to a lead; conversationHoldsWake skips it.
29
+ export const FIRST_MESSAGE_LOG_STATE = "first_message";
30
+ export function isHiveFirstMessage(payload) {
13
31
  if (typeof payload.prompt !== "string")
14
32
  return false;
15
33
  const prompt = payload.prompt.trim();
@@ -41,24 +59,86 @@ export function nextLeadTurn(cur, panePid, event, payload, subagentsLive) {
41
59
  return null;
42
60
  }
43
61
  }
44
- const applyInTransaction = db.transaction((agentId, panePid, event, payload, subagentsLive) => {
45
- const cur = readLeadTurnState(agentId);
46
- const next = nextLeadTurn(cur, panePid, event, payload, subagentsLive);
47
- if (next === null)
62
+ export function readLeadSafetySnapshot(agentId) {
63
+ return (db.prepare("SELECT * FROM lead_turn_state WHERE agent_id = ?").get(agentId) ?? null);
64
+ }
65
+ function applySnapshot(agentId, panePid, event, payload) {
66
+ const row = readLeadSafetySnapshot(agentId);
67
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
68
+ if (row === null || sessionId === "" || row.pane_pid !== panePid || row.session_id !== sessionId)
48
69
  return;
49
- if (cur !== null &&
50
- cur.pane_pid === next.pane_pid &&
51
- cur.session_id === next.session_id &&
52
- cur.state === next.state &&
53
- cur.idle_seq === next.idle_seq &&
54
- (event !== "session_end" || cur.last_event === "session_end")) {
70
+ const fresh = row.snapshot_pane_pid !== panePid || row.snapshot_session_id !== sessionId;
71
+ const snap = {
72
+ prompt_seq: fresh ? 0 : row.prompt_seq,
73
+ human_prompt_seq: fresh ? 0 : row.human_prompt_seq,
74
+ human_prompt_at: fresh ? null : row.human_prompt_at,
75
+ transcript_path: fresh ? "" : row.transcript_path,
76
+ stop_prompt_seq: fresh ? null : row.stop_prompt_seq,
77
+ stop_background: fresh ? null : row.stop_background,
78
+ stop_at: fresh ? null : row.stop_at,
79
+ };
80
+ if (typeof payload.transcript_path === "string" && payload.transcript_path !== "") {
81
+ snap.transcript_path = payload.transcript_path;
82
+ }
83
+ const now = db.prepare("SELECT strftime('%Y-%m-%d %H:%M:%f', 'now') AS now").get().now;
84
+ switch (event) {
85
+ case "prompt":
86
+ snap.prompt_seq += 1;
87
+ if (typeof payload.prompt === "string" && !isHiveFirstMessage(payload) && isHumanPrompt(payload.prompt.trim())) {
88
+ snap.human_prompt_seq += 1;
89
+ snap.human_prompt_at = now;
90
+ // A human prompt during grace postpones that attempt at once; the grace owner sees it too.
91
+ const active = readActiveHandoff(agentId);
92
+ if (active?.state === "grace" && active.predecessor_session_id === sessionId) {
93
+ postponeGrace(active, { predecessor_session_id: sessionId }, "a human prompt arrived during grace", true);
94
+ }
95
+ }
96
+ break;
97
+ case "stop":
98
+ snap.stop_prompt_seq = snap.prompt_seq;
99
+ snap.stop_at = now;
100
+ // No background_tasks in the payload is unknown evidence, never an empty list.
101
+ snap.stop_background = payload.background_tasks === undefined
102
+ ? null
103
+ : JSON.stringify(liveBackgroundTasks(payload.background_tasks));
104
+ break;
105
+ }
106
+ db.prepare(`UPDATE lead_turn_state SET snapshot_pane_pid = ?, snapshot_session_id = ?, prompt_seq = ?, human_prompt_seq = ?,
107
+ human_prompt_at = ?, transcript_path = ?, stop_prompt_seq = ?, stop_background = ?, stop_at = ?
108
+ WHERE agent_id = ?`).run(panePid, sessionId, snap.prompt_seq, snap.human_prompt_seq, snap.human_prompt_at, snap.transcript_path, snap.stop_prompt_seq, snap.stop_background, snap.stop_at, agentId);
109
+ }
110
+ // The states after a respawn claim, in which the predecessor's session is no longer the lead.
111
+ export const HANDED_OFF_STATES_SQL = "('respawning', 'started', 'completed', 'ambiguous')";
112
+ // A predecessor-session hook is fenced from the respawn claim until the handoff completes or fails;
113
+ // the pane pid cannot tell a dying predecessor from an in-pane /resume, so the row's state decides.
114
+ export function predecessorFence(agentId, sessionId) {
115
+ if (typeof sessionId !== "string" || sessionId === "")
116
+ return false;
117
+ return (db
118
+ .prepare(`SELECT 1 AS hit FROM lead_handoffs WHERE lead_agent_id = ? AND predecessor_session_id = ?
119
+ AND state IN ('respawning', 'started', 'ambiguous')`)
120
+ .get(agentId, sessionId) !== undefined);
121
+ }
122
+ const applyInTransaction = db.transaction((agentId, panePid, event, payload, subagentsLive, fenced) => {
123
+ if (fenced)
55
124
  return;
125
+ const cur = readLeadTurnState(agentId);
126
+ const next = nextLeadTurn(cur, panePid, event, payload, subagentsLive);
127
+ const unchanged = next === null ||
128
+ (cur !== null &&
129
+ cur.pane_pid === next.pane_pid &&
130
+ cur.session_id === next.session_id &&
131
+ cur.state === next.state &&
132
+ cur.idle_seq === next.idle_seq &&
133
+ (event !== "session_end" || cur.last_event === "session_end"));
134
+ if (!unchanged) {
135
+ db.prepare(`INSERT INTO lead_turn_state (agent_id, pane_pid, session_id, state, idle_seq, last_event, changed_at)
136
+ VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', 'now'))
137
+ ON CONFLICT(agent_id) DO UPDATE SET pane_pid = excluded.pane_pid, session_id = excluded.session_id,
138
+ state = excluded.state, idle_seq = excluded.idle_seq, last_event = excluded.last_event,
139
+ changed_at = excluded.changed_at`).run(agentId, next.pane_pid, next.session_id, next.state, next.idle_seq, event);
56
140
  }
57
- db.prepare(`INSERT INTO lead_turn_state (agent_id, pane_pid, session_id, state, idle_seq, last_event, changed_at)
58
- VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', 'now'))
59
- ON CONFLICT(agent_id) DO UPDATE SET pane_pid = excluded.pane_pid, session_id = excluded.session_id,
60
- state = excluded.state, idle_seq = excluded.idle_seq, last_event = excluded.last_event,
61
- changed_at = excluded.changed_at`).run(agentId, next.pane_pid, next.session_id, next.state, next.idle_seq, event);
141
+ applySnapshot(agentId, panePid, event, payload);
62
142
  });
63
143
  export function applyLeadHook(actorId, event, payload, subagentsLive) {
64
144
  const lead = db
@@ -66,5 +146,6 @@ export function applyLeadHook(actorId, event, payload, subagentsLive) {
66
146
  .get(actorId);
67
147
  if (!lead)
68
148
  return;
69
- applyInTransaction.immediate(lead.id, lead.pane_pid, event, payload, subagentsLive);
149
+ const fenced = predecessorFence(lead.id, payload.session_id);
150
+ applyInTransaction.immediate(lead.id, lead.pane_pid, event, payload, subagentsLive, fenced);
70
151
  }
package/dist/portfolio.js CHANGED
@@ -3,10 +3,12 @@ import { listProjects } from "./context.js";
3
3
  import { existsSync } from "node:fs";
4
4
  import { parseTags } from "./result.js";
5
5
  import { OPEN_BLOCKERS_SQL } from "./tools/todos.js";
6
+ import { pendingQuestionCount } from "./questions.js";
6
7
  import { leadSessionEnded, readLeadTurnState } from "./leadState.js";
7
8
  import { liveTargets, ownershipLiveness, rowOwnership } from "./tmux.js";
8
9
  export const PORTFOLIO_REASONS = [
9
10
  "needs_human",
11
+ "questions_pending",
10
12
  "dead_lead_pane",
11
13
  "missing_root_with_work",
12
14
  "worker_needs_input",
@@ -135,9 +137,12 @@ function projectRow(project, now, snapshot) {
135
137
  const staleClock = lastActivity(project.id, false);
136
138
  const staleBefore = shiftedClock(now, STALE_IN_PROGRESS);
137
139
  const activeTodos = todos.open + todos.in_progress;
140
+ const questionsPending = pendingQuestionCount(project.id);
138
141
  const found = new Set();
139
142
  if (tagged.length > 0)
140
143
  found.add("needs_human");
144
+ if (questionsPending > 0)
145
+ found.add("questions_pending");
141
146
  if ((lead.state === "dead_pane" || lead.state === "dormant") && (todos.in_progress > 0 || wakeCounts.pending > 0))
142
147
  found.add("dead_lead_pane");
143
148
  if (!rootExists && (activeTodos > 0 || wakeCounts.pending > 0))
@@ -172,7 +177,7 @@ function projectRow(project, now, snapshot) {
172
177
  lane = "stuck";
173
178
  else if (movingSignal)
174
179
  lane = "moving";
175
- else if (found.has("needs_human"))
180
+ else if (found.has("needs_human") || found.has("questions_pending"))
176
181
  lane = "waiting_on_you";
177
182
  else if (TODO_GRAPH.some((r) => found.has(r)))
178
183
  lane = "stuck";
@@ -191,6 +196,7 @@ function projectRow(project, now, snapshot) {
191
196
  workers,
192
197
  todos,
193
198
  needs_human: tagged.length,
199
+ questions_pending: questionsPending,
194
200
  needs_human_items: tagged.slice(0, NEEDS_HUMAN_ITEM_CAP).map((t) => ({
195
201
  todo_id: t.id,
196
202
  title: t.title,
@@ -10,6 +10,7 @@ export const PROJECT_OWNER_TABLES = [
10
10
  "agents",
11
11
  "wakes",
12
12
  "command_trust",
13
+ "questions",
13
14
  ];
14
15
  const COUNTED_TABLES = [
15
16
  ...PROJECT_OWNER_TABLES.map((t) => [t, `SELECT COUNT(*) AS n FROM ${t} WHERE project_id = ?`]),
@@ -180,7 +180,14 @@ function applyLayer(root, path, source, config, sources, warnings) {
180
180
  }
181
181
  else if (isMapping(value) && typeof value.warn === "number" && Number.isInteger(value.warn) && value.warn > 0 &&
182
182
  typeof value.stop === "number" && Number.isInteger(value.stop) && value.stop > value.warn) {
183
- config.lead_turn_budget = { warn: value.warn, stop: value.stop };
183
+ const budget = { warn: value.warn, stop: value.stop };
184
+ if (value.auto_handoff !== undefined) {
185
+ if (typeof value.auto_handoff === "boolean")
186
+ budget.auto_handoff = value.auto_handoff;
187
+ else
188
+ warn(`lead_turn_budget.auto_handoff must be true or false; ignoring "${String(value.auto_handoff)}", so automatic handoff stays off.`);
189
+ }
190
+ config.lead_turn_budget = budget;
184
191
  sources.lead_turn_budget = source;
185
192
  }
186
193
  else {
@@ -13,6 +13,11 @@ function removalSummary(r) {
13
13
  const counts = Object.entries((r.counts ?? {})).map(([t, n]) => `${t} ${n}`);
14
14
  return `removed project "${r.name}"${counts.length > 0 ? ` with ${counts.join(", ")}` : ""}`;
15
15
  }
16
+ function insertQueenAuditRow(operation, targetProjectId, identity, resourceType, resourceId, summary, outcome = "confirmed", problem = "") {
17
+ db.prepare(`INSERT INTO queen_audit
18
+ (actor_id, home_project_id, target_project_id, operation, resource_type, resource_id, summary, outcome, problem)
19
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(identity.actor_id, identity.home_project_id, targetProjectId, operation, resourceType, resourceId, summary, outcome, problem);
20
+ }
16
21
  export const QUEEN_AUDIT_OPERATIONS = {
17
22
  todo_create: {
18
23
  mode: "database",
@@ -78,8 +83,15 @@ function insertAudit(operation, targetProjectId, args, receipt, identity) {
78
83
  return;
79
84
  const text = flatten(spec.summary(args, receipt));
80
85
  const summary = text.length <= QUEEN_AUDIT_SUMMARY_MAX ? text : cutToUnitBudget(text, QUEEN_AUDIT_SUMMARY_MAX - 1) + "…";
81
- db.prepare(`INSERT INTO queen_audit (actor_id, home_project_id, target_project_id, operation, resource_type, resource_id, summary)
82
- VALUES (?, ?, ?, ?, ?, ?, ?)`).run(identity.actor_id, identity.home_project_id, targetProjectId, operation, touched.type, touched.id, summary);
86
+ insertQueenAuditRow(operation, targetProjectId, identity, touched.type, touched.id, summary);
87
+ }
88
+ export function recordQueenRefusal(operation, targetProjectId, problem, identity) {
89
+ const text = flatten(problem);
90
+ const summaryText = `refused: ${text}`;
91
+ const summary = summaryText.length <= QUEEN_AUDIT_SUMMARY_MAX
92
+ ? summaryText
93
+ : cutToUnitBudget(summaryText, QUEEN_AUDIT_SUMMARY_MAX - 1) + "…";
94
+ insertQueenAuditRow(operation, targetProjectId, identity, "project", targetProjectId, summary, "refused", text);
83
95
  }
84
96
  export function commitQueenStateWrite(operation, targetProjectId, args, mutate) {
85
97
  const identity = captureQueenWriteIdentity(targetProjectId);
@@ -84,6 +84,7 @@ const LANE_COLOR = {
84
84
  };
85
85
  const REASON_LABEL = {
86
86
  needs_human: "Needs-human todo",
87
+ questions_pending: "Pending question",
87
88
  dead_lead_pane: "Lead pane is dead",
88
89
  missing_root_with_work: "Project folder is missing",
89
90
  worker_needs_input: "A worker is waiting for input",
@@ -210,10 +211,12 @@ function footCounts(p, asOf, rank) {
210
211
  (rank ? `<span class="age">seen ${esc(age(p.last_activity_at, asOf))}</span>` : ""));
211
212
  }
212
213
  function movingBadges(p) {
213
- if (p.lane !== "moving")
214
- return "";
215
- return ((p.needs_human > 0 ? `<span class="n-needs">${p.needs_human} need you</span>` : "") +
216
- (p.todos.blocked_in_progress > 0 ? `<span class="n-muted">${p.todos.blocked_in_progress} blocked</span>` : ""));
214
+ const questions = p.questions_pending > 0
215
+ ? `<span class="n-questions">${p.questions_pending} question${p.questions_pending === 1 ? "" : "s"}</span>`
216
+ : "";
217
+ return ((p.lane === "moving" && p.needs_human > 0 ? `<span class="n-needs">${p.needs_human} need you</span>` : "") +
218
+ questions +
219
+ (p.lane === "moving" && p.todos.blocked_in_progress > 0 ? `<span class="n-muted">${p.todos.blocked_in_progress} blocked</span>` : ""));
217
220
  }
218
221
  function card(p, report, rank, href) {
219
222
  const asOf = report.as_of;
@@ -226,7 +229,7 @@ function card(p, report, rank, href) {
226
229
  items.push(`<li><span class="ref">+${p.needs_human - p.needs_human_items.length} more</span></li>`);
227
230
  }
228
231
  for (const r of p.reasons) {
229
- if (r === "needs_human" || r === "quiet")
232
+ if (r === "needs_human" || r === "questions_pending" || r === "quiet")
230
233
  continue;
231
234
  const cls = REASON_SEVERE.has(r) ? " age-fail" : "";
232
235
  items.push(`<li><span class="${cls.trim()}">${esc(REASON_LABEL[r])}</span></li>`);
@@ -263,19 +266,22 @@ function gridRow(p, asOf, href) {
263
266
  `<span class="c-work">${workersCell(p)}</span>` +
264
267
  `<span class="c-n g-num">${num(t.open)}</span><span class="c-n g-num">${num(t.in_progress)}</span>` +
265
268
  `<span class="c-n g-num">${num(t.blocked, "n-blocked")}</span><span class="c-n g-num">${num(t.high, "n-high")}</span>` +
266
- `<span class="c-n g-num">${num(p.needs_human, "n-needs")}</span><span class="c-n g-num c-wake">${wakesCell(p)}</span>` +
269
+ `<span class="c-n g-num">${num(p.needs_human, "n-needs")}</span>` +
270
+ `<span class="c-n g-num">${num(p.questions_pending, "n-questions")}</span>` +
271
+ `<span class="c-n g-num c-wake">${wakesCell(p)}</span>` +
267
272
  `<span class="c-last g-num age">${esc(age(p.last_activity_at, asOf))}</span>` +
268
273
  '<span class="c-nums">' +
269
274
  `<span>${t.open} open</span><span>${t.in_progress} doing</span>` +
270
275
  (t.blocked ? `<span class="n-blocked">${t.blocked} blocked</span>` : "") +
271
276
  (t.high ? `<span class="n-high">${t.high} high</span>` : "") +
272
277
  (p.needs_human ? `<span class="n-needs">${p.needs_human} need you</span>` : "") +
278
+ (p.questions_pending ? `<span class="n-questions">${p.questions_pending} question${p.questions_pending === 1 ? "" : "s"}</span>` : "") +
273
279
  wakesText(p) +
274
280
  "</span></div>");
275
281
  }
276
282
  const GRID_HEAD = '<div class="g-row g-head" role="row"><span>Project</span><span>Lead</span><span>Workers</span>' +
277
283
  '<span class="g-num">Open</span><span class="g-num">Doing</span><span class="g-num">Blocked</span>' +
278
- '<span class="g-num">High</span><span class="g-num">Needs you</span><span class="g-num c-wake">Wakes</span>' +
284
+ '<span class="g-num">High</span><span class="g-num">Needs you</span><span class="g-num">Questions</span><span class="g-num c-wake">Wakes</span>' +
279
285
  '<span class="g-num">Last seen</span></div>';
280
286
  function laneName(lane) {
281
287
  return LANE_TITLE[lane].toLowerCase();
@@ -409,7 +415,7 @@ a.proj:hover { color: var(--accent); text-decoration: underline; text-underline-
409
415
  .drift { margin: 0.5rem 0 0; grid-column: 1 / -1; padding: 0.55rem 0.75rem; border-radius: var(--r-ctl); background: var(--warn-bg); color: var(--fg); font-size: 0.8125rem; }
410
416
  .drift strong { color: var(--warn); font-weight: 650; }
411
417
  .grid { display: grid; }
412
- .g-row { display: grid; grid-template-columns: minmax(8.5rem, 1.3fr) 10.5rem minmax(8rem, 1.2fr) repeat(4, 3.2rem) 4.6rem 5.2rem 5.6rem; align-items: center; gap: 0.6rem; padding: 0.5rem 1.1rem; border-top: 1px solid var(--border); font-size: 0.875rem; }
418
+ .g-row { display: grid; grid-template-columns: minmax(8.5rem, 1.3fr) 10.5rem minmax(8rem, 1.2fr) repeat(4, 3.2rem) 4.6rem 4.6rem 5.2rem 5.6rem; align-items: center; gap: 0.6rem; padding: 0.5rem 1.1rem; border-top: 1px solid var(--border); font-size: 0.875rem; }
413
419
  .g-row:hover:not(.g-head) { background: var(--hover); }
414
420
  .g-head { font-size: 0.75rem; color: var(--fg-subtle); font-weight: 550; border-top: 0; padding-block: 0.2rem 0.4rem; }
415
421
  .g-num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; }
@@ -0,0 +1,279 @@
1
+ import { db } from "./db.js";
2
+ import { HUMAN_ACTOR_PREFIX } from "./context.js";
3
+ import { assertSafeWakeBody, resolveWakeDelivery } from "./tools/wakes.js";
4
+ const QUESTION_STATE_CONFLICT = "QUESTION_STATE_CONFLICT";
5
+ const HEADER_MAX_CHARS = 12;
6
+ const MIN_OPTIONS = 2;
7
+ const MAX_OPTIONS = 4;
8
+ function toRecord(row) {
9
+ const { ref_kind, ref_number, options, ...rest } = row;
10
+ return {
11
+ ...rest,
12
+ options: options === null ? null : JSON.parse(options),
13
+ ref: ref_kind === null || ref_number === null ? null : { kind: ref_kind, number: ref_number },
14
+ };
15
+ }
16
+ const blank = (s) => typeof s !== "string" || s.trim() === "";
17
+ function validateRef(ref) {
18
+ if (ref === undefined)
19
+ return;
20
+ if (ref.kind !== "todo" && ref.kind !== "pr")
21
+ throw new Error(`ref.kind must be "todo" or "pr", got ${JSON.stringify(ref.kind)}.`);
22
+ if (!Number.isSafeInteger(ref.number) || ref.number <= 0)
23
+ throw new Error("ref.number must be a positive safe integer.");
24
+ }
25
+ function validateInput(input) {
26
+ if (input.kind !== "choice" && input.kind !== "free_text") {
27
+ throw new Error(`kind must be "choice" or "free_text", got ${JSON.stringify(input.kind)}.`);
28
+ }
29
+ if (blank(input.question))
30
+ throw new Error("question cannot be blank.");
31
+ if (blank(input.header))
32
+ throw new Error("header cannot be blank.");
33
+ if ([...input.header].length > HEADER_MAX_CHARS) {
34
+ throw new Error(`header is ${[...input.header].length} characters; the limit is ${HEADER_MAX_CHARS}.`);
35
+ }
36
+ if (blank(input.default_answer))
37
+ throw new Error("default_answer cannot be blank: every question carries its default.");
38
+ validateRef(input.ref);
39
+ const wakeBound = [input.question, input.header, input.default_answer];
40
+ if (input.kind === "free_text") {
41
+ if (input.options !== undefined)
42
+ throw new Error("a free_text question takes no options.");
43
+ }
44
+ else {
45
+ const options = input.options;
46
+ if (!Array.isArray(options) || options.length < MIN_OPTIONS || options.length > MAX_OPTIONS) {
47
+ throw new Error(`a choice question needs ${MIN_OPTIONS} to ${MAX_OPTIONS} options, got ${Array.isArray(options) ? options.length : 0}.`);
48
+ }
49
+ const labels = new Set();
50
+ for (const o of options) {
51
+ if (blank(o.label))
52
+ throw new Error("an option label cannot be blank.");
53
+ if (blank(o.description))
54
+ throw new Error(`option "${o.label}" needs a description.`);
55
+ if (labels.has(o.label))
56
+ throw new Error(`option label "${o.label}" is duplicated; labels must be distinct.`);
57
+ labels.add(o.label);
58
+ wakeBound.push(o.label, o.description);
59
+ }
60
+ const recommended = options.filter((o) => o.recommended);
61
+ if (recommended.length !== 1) {
62
+ throw new Error(`exactly one option must be recommended, got ${recommended.length}.`);
63
+ }
64
+ if (input.default_answer !== recommended[0].label) {
65
+ throw new Error(`default_answer must be the recommended option's label ("${recommended[0].label}").`);
66
+ }
67
+ }
68
+ for (const text of wakeBound)
69
+ assertSafeWakeBody(text);
70
+ }
71
+ const tokensOf = (text) => new Set((text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).filter((t) => [...t].length >= 4));
72
+ const normalized = (text) => text.trim().replace(/\s+/g, " ").toLowerCase();
73
+ export function looksSimilar(a, b) {
74
+ if (normalized(a) === normalized(b))
75
+ return true;
76
+ const ta = tokensOf(a);
77
+ const tb = tokensOf(b);
78
+ let shared = 0;
79
+ for (const t of ta)
80
+ if (tb.has(t))
81
+ shared++;
82
+ const union = ta.size + tb.size - shared;
83
+ return union > 0 && shared / union >= 0.5;
84
+ }
85
+ function similarPendingIds(projectId, selfId, question) {
86
+ const rows = db
87
+ .prepare("SELECT id, question FROM questions WHERE project_id = ? AND state = 'pending' AND id != ? ORDER BY id")
88
+ .all(projectId, selfId);
89
+ return rows.filter((r) => looksSimilar(question, r.question)).map((r) => r.id);
90
+ }
91
+ function runningAgentOf(projectId, actor) {
92
+ const row = db
93
+ .prepare("SELECT id FROM agents WHERE project_id = ? AND actor_id = ? AND status = 'running' ORDER BY id DESC LIMIT 1")
94
+ .get(projectId, actor);
95
+ return row?.id ?? null;
96
+ }
97
+ function runningLeadOf(projectId) {
98
+ const row = db
99
+ .prepare("SELECT id, actor_id FROM agents WHERE project_id = ? AND kind = 'lead' AND status = 'running' ORDER BY id DESC LIMIT 1")
100
+ .get(projectId);
101
+ return row ?? null;
102
+ }
103
+ const optionsJson = (input) => (input.kind === "choice" ? JSON.stringify(input.options) : null);
104
+ function pendingIdForRef(projectId, ref) {
105
+ const row = db
106
+ .prepare("SELECT id FROM questions WHERE project_id = ? AND ref_kind = ? AND ref_number = ? AND state = 'pending'")
107
+ .get(projectId, ref.kind, ref.number);
108
+ return row?.id;
109
+ }
110
+ const upsertQuestion = db.transaction((projectId, actor, input, askerChecked) => {
111
+ if (input.ref) {
112
+ const existing = pendingIdForRef(projectId, input.ref);
113
+ if (existing !== undefined) {
114
+ db.prepare(`UPDATE questions SET kind = ?, question = ?, header = ?, options = ?, default_answer = ?,
115
+ updated_at = datetime('now'), updated_by = ? WHERE id = ?`).run(input.kind, input.question, input.header, optionsJson(input), input.default_answer, actor, existing);
116
+ return { id: existing, created: false };
117
+ }
118
+ }
119
+ if (!askerChecked) {
120
+ throw new Error(`${QUESTION_STATE_CONFLICT}: the pending question for this ref changed while it was being asked; ask again.`);
121
+ }
122
+ const row = db
123
+ .prepare(`INSERT INTO questions (project_id, asker, kind, question, header, options, default_answer,
124
+ ref_kind, ref_number, updated_by)
125
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`)
126
+ .get(projectId, actor, input.kind, input.question, input.header, optionsJson(input), input.default_answer, input.ref?.kind ?? null, input.ref?.number ?? null, actor);
127
+ return { id: row.id, created: true };
128
+ });
129
+ export function askQuestion(projectId, actor, input) {
130
+ validateInput(input);
131
+ const refTaken = input.ref !== undefined && pendingIdForRef(projectId, input.ref) !== undefined;
132
+ let askerChecked = false;
133
+ if (!refTaken) {
134
+ resolveWakeDelivery(projectId);
135
+ askerChecked = runningAgentOf(projectId, actor) !== null;
136
+ if (!askerChecked) {
137
+ throw new Error("This session has no running hive agent row, so an answer could never be woken back to it; a question needs an asker that can be woken.");
138
+ }
139
+ }
140
+ const { id, created } = upsertQuestion.immediate(projectId, actor, input, askerChecked);
141
+ return { question_id: id, created, similar_question_ids: similarPendingIds(projectId, id, input.question) };
142
+ }
143
+ function fetchRow(projectId, id) {
144
+ const row = db.prepare("SELECT * FROM questions WHERE project_id = ? AND id = ?").get(projectId, id);
145
+ if (!row)
146
+ throw new Error(`No question ${id} in this project. Call question_list.`);
147
+ return row;
148
+ }
149
+ export function getQuestion(projectId, id) {
150
+ return toRecord(fetchRow(projectId, id));
151
+ }
152
+ export function listQuestions(projectId) {
153
+ const rows = db
154
+ .prepare("SELECT * FROM questions WHERE project_id = ? AND state IN ('pending', 'defaulted') ORDER BY (state = 'defaulted'), id")
155
+ .all(projectId);
156
+ return rows.map(toRecord);
157
+ }
158
+ export function pendingQuestionCount(projectId) {
159
+ return db.prepare("SELECT COUNT(*) AS n FROM questions WHERE project_id = ? AND state = 'pending'").get(projectId).n;
160
+ }
161
+ const TEXT_PATCH_KEYS = ["question", "header", "options", "default_answer"];
162
+ const patchQuestion = db.transaction((projectId, actor, id, patch) => {
163
+ const current = toRecord(fetchRow(projectId, id));
164
+ const editsText = TEXT_PATCH_KEYS.some((k) => patch[k] !== undefined);
165
+ if (editsText && current.state !== "pending") {
166
+ throw new Error(`${QUESTION_STATE_CONFLICT}: question ${id} is ${current.state}; only a pending question's text can be edited.`);
167
+ }
168
+ if (patch.state !== undefined) {
169
+ if (patch.state !== "defaulted" && patch.state !== "withdrawn") {
170
+ throw new Error(`state must be "defaulted" or "withdrawn", got ${JSON.stringify(patch.state)}.`);
171
+ }
172
+ const allowed = patch.state === "withdrawn" ? ["pending", "defaulted"] : ["pending"];
173
+ if (!allowed.includes(current.state)) {
174
+ throw new Error(`${QUESTION_STATE_CONFLICT}: question ${id} is ${current.state} and cannot become ${patch.state}.`);
175
+ }
176
+ if (actor !== current.asker && !actor.startsWith(HUMAN_ACTOR_PREFIX)) {
177
+ throw new Error(`Only the asking session (${current.asker}) or the human can mark question ${id} ${patch.state}.`);
178
+ }
179
+ }
180
+ let merged = null;
181
+ if (editsText) {
182
+ if (current.kind === "free_text" && patch.options !== undefined)
183
+ throw new Error("a free_text question takes no options.");
184
+ const base = {
185
+ question: patch.question ?? current.question,
186
+ header: patch.header ?? current.header,
187
+ default_answer: patch.default_answer ?? current.default_answer,
188
+ ...(current.ref ? { ref: current.ref } : {}),
189
+ };
190
+ merged =
191
+ current.kind === "choice"
192
+ ? { kind: "choice", ...base, options: patch.options ?? current.options }
193
+ : { kind: "free_text", ...base };
194
+ validateInput(merged);
195
+ }
196
+ const sets = ["updated_at = datetime('now')", "updated_by = ?"];
197
+ const params = [actor];
198
+ if (merged) {
199
+ sets.push("question = ?", "header = ?", "options = ?", "default_answer = ?");
200
+ params.push(merged.question, merged.header, optionsJson(merged), merged.default_answer);
201
+ }
202
+ if (patch.state !== undefined) {
203
+ sets.push("state = ?");
204
+ params.push(patch.state);
205
+ }
206
+ db.prepare(`UPDATE questions SET ${sets.join(", ")} WHERE id = ?`).run(...params, id);
207
+ return { question_id: id, state: patch.state ?? current.state };
208
+ });
209
+ export function updateQuestion(projectId, actor, id, patch) {
210
+ if (Object.keys(patch).filter((k) => patch[k] !== undefined).length === 0) {
211
+ throw new Error("an empty patch changes nothing; pass at least one field to change.");
212
+ }
213
+ return patchQuestion.immediate(projectId, actor, id, patch);
214
+ }
215
+ export function answerWakeBody(id, question, answer, notes) {
216
+ const head = `Question #${id} answered.\nQuestion: ${question}\nAnswer: ${answer}`;
217
+ return notes !== null && notes.trim() !== "" ? `${head}\nNotes: ${notes}` : head;
218
+ }
219
+ function planAnswerWake(projectId, row, actor) {
220
+ if (actor === row.asker)
221
+ return { skipped: "answered by the asker" };
222
+ const agentId = runningAgentOf(projectId, row.asker);
223
+ if (agentId === null) {
224
+ const lead = runningLeadOf(projectId);
225
+ if (lead === null)
226
+ return { skipped: `the asker ${row.asker} has no running agent` };
227
+ if (lead.actor_id === actor)
228
+ return { skipped: "answered by the lead that would receive it" };
229
+ try {
230
+ return { delivery: resolveWakeDelivery(projectId, lead.id), reroutedFrom: row.asker };
231
+ }
232
+ catch (e) {
233
+ return { skipped: e instanceof Error ? e.message : String(e) };
234
+ }
235
+ }
236
+ try {
237
+ return { delivery: resolveWakeDelivery(projectId, agentId) };
238
+ }
239
+ catch (e) {
240
+ return { skipped: e instanceof Error ? e.message : String(e) };
241
+ }
242
+ }
243
+ const commitAnswer = db.transaction((projectId, actor, id, answer, notes, plan) => {
244
+ const row = fetchRow(projectId, id);
245
+ if (row.state !== "pending" && row.state !== "defaulted") {
246
+ throw new Error(`${QUESTION_STATE_CONFLICT}: question ${id} is already ${row.state}.`);
247
+ }
248
+ db.prepare(`UPDATE questions SET state = 'answered', answer = ?, notes = ?, answered_by = ?, updated_by = ?,
249
+ updated_at = datetime('now') WHERE id = ?`).run(answer, notes, actor, actor, id);
250
+ if ("skipped" in plan)
251
+ return { question_id: id, wake_id: null, wake_skipped: plan.skipped };
252
+ const body = answerWakeBody(id, row.question, answer, notes);
253
+ const wakeBody = "reroutedFrom" in plan && plan.reroutedFrom !== undefined
254
+ ? `${body}\nAsked by ${plan.reroutedFrom}, which is not running; delivered to this project's lead.`
255
+ : body;
256
+ const wake = db
257
+ .prepare(`INSERT INTO wakes (project_id, owner, body, kind, deliver_actor, deliver_pane, due_at)
258
+ VALUES (?, ?, ?, 'delay', ?, ?, datetime('now')) RETURNING id`)
259
+ .get(projectId, actor, wakeBody, plan.delivery.actor, plan.delivery.pane);
260
+ return {
261
+ question_id: id,
262
+ wake_id: wake.id,
263
+ ...("reroutedFrom" in plan && plan.reroutedFrom !== undefined ? { wake_rerouted_to: plan.delivery.actor } : {}),
264
+ };
265
+ });
266
+ export function answerQuestion(projectId, actor, id, answer, notes) {
267
+ if (blank(answer))
268
+ throw new Error("answer cannot be blank.");
269
+ const storedNotes = notes === undefined || notes.trim() === "" ? null : notes;
270
+ assertSafeWakeBody(answer);
271
+ if (storedNotes !== null)
272
+ assertSafeWakeBody(storedNotes);
273
+ const row = fetchRow(projectId, id);
274
+ if (row.state !== "pending" && row.state !== "defaulted") {
275
+ throw new Error(`${QUESTION_STATE_CONFLICT}: question ${id} is already ${row.state}.`);
276
+ }
277
+ const plan = planAnswerWake(projectId, row, actor);
278
+ return commitAnswer.immediate(projectId, actor, id, answer, storedNotes, plan);
279
+ }