@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/README.md +1 -0
- package/claude-plugin/crew/model.mjs +2 -0
- package/dist/build-info.json +1 -1
- package/dist/cli.js +135 -52
- package/dist/context.js +49 -5
- package/dist/crew.js +20 -2
- package/dist/dashboard.js +52 -0
- package/dist/db.js +144 -0
- package/dist/help.js +27 -0
- package/dist/hook.js +32 -2
- package/dist/index.js +2 -0
- package/dist/kickoff.js +29 -6
- package/dist/leadHandoff.js +525 -0
- package/dist/leadHandoffCli.js +295 -0
- package/dist/leadHandoffState.js +96 -0
- package/dist/leadLaunch.js +54 -0
- package/dist/leadMessageDelivery.js +44 -7
- package/dist/leadState.js +98 -17
- package/dist/portfolio.js +7 -1
- package/dist/projectRemove.js +1 -0
- package/dist/projectYml.js +8 -1
- package/dist/queenAudit.js +14 -2
- package/dist/queenDashboard.js +14 -8
- package/dist/questions.js +279 -0
- package/dist/scheduler.js +25 -5
- package/dist/staleServers.js +135 -0
- package/dist/tools/agents.js +2 -2
- package/dist/tools/meta.js +4 -1
- package/dist/tools/queenAudit.js +1 -1
- package/dist/tools/questions.js +88 -0
- package/dist/tools/wakes.js +25 -17
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/projectRemove.js
CHANGED
package/dist/projectYml.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/dist/queenAudit.js
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
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);
|
package/dist/queenDashboard.js
CHANGED
|
@@ -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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
+
}
|