@cmgmyr/hive 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/claude-plugin/README.md +1 -1
- package/dist/brief.js +1 -1
- package/dist/build-info.json +1 -1
- package/dist/cli.js +419 -56
- package/dist/context.js +193 -2
- package/dist/db.js +61 -18
- package/dist/firstMessage.js +5 -0
- package/dist/harnesses.js +6 -4
- package/dist/help.js +32 -11
- package/dist/hook.js +9 -0
- package/dist/index.js +11 -5
- package/dist/kickoff.js +41 -27
- package/dist/leadState.js +63 -0
- package/dist/leadWatch.js +73 -0
- package/dist/portfolio.js +210 -0
- package/dist/projectRemove.js +68 -0
- package/dist/projectYml.js +19 -0
- package/dist/queenAudit.js +132 -0
- package/dist/queenDashboard.js +573 -0
- package/dist/scheduler.js +179 -15
- package/dist/strictInput.js +24 -7
- package/dist/tmux.js +82 -6
- package/dist/tools/agents.js +4 -0
- package/dist/tools/leases.js +2 -2
- package/dist/tools/meta.js +51 -19
- package/dist/tools/queenAudit.js +13 -0
- package/dist/tools/todos.js +18 -13
- package/dist/tools/wakes.js +197 -65
- package/package.json +4 -4
- package/profiles/orchestration/worker.md +1 -1
- package/profiles/queen/posture.md +31 -0
- package/profiles/queen/runbook.md +26 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
import { paneClassifierFor } from "./harnesses.js";
|
|
3
|
+
import { readLeadTurnState } from "./leadState.js";
|
|
4
|
+
import { LEAD_KIND } from "./spawn.js";
|
|
5
|
+
import { holdsHumanInput, paneReissued, rowAliveProbe } from "./tmux.js";
|
|
6
|
+
export const LEAD_TARGET_GONE = "LEAD_TARGET_GONE";
|
|
7
|
+
export const LEAD_TARGET_REISSUED = "LEAD_TARGET_REISSUED";
|
|
8
|
+
export const LEAD_TARGET_RESTARTED = "LEAD_TARGET_RESTARTED";
|
|
9
|
+
const directPaneReaders = {
|
|
10
|
+
awaitingChoice: (pane, command) => paneClassifierFor(command)?.choiceCheck(pane).awaitingChoice ?? null,
|
|
11
|
+
holdsInput: (pane, command) => holdsHumanInput(paneClassifierFor(command)?.inputBoxState(pane) ?? null),
|
|
12
|
+
};
|
|
13
|
+
export function leadSubscription(wakeId) {
|
|
14
|
+
return (db.prepare("SELECT * FROM lead_idle_subscriptions WHERE wake_id = ?").get(wakeId) ??
|
|
15
|
+
null);
|
|
16
|
+
}
|
|
17
|
+
export function runningLeadOf(projectId) {
|
|
18
|
+
return (db
|
|
19
|
+
.prepare(`SELECT id, name, status, tmux_target, tmux_socket, pane_pid, command FROM agents
|
|
20
|
+
WHERE project_id = ? AND kind = ? AND status = 'running' ORDER BY id LIMIT 1`)
|
|
21
|
+
.get(projectId, LEAD_KIND) ?? null);
|
|
22
|
+
}
|
|
23
|
+
// Reads the watched lead's screen only to stay quiet; it can never make a lead read idle.
|
|
24
|
+
export function paneVeto(lead, readers = directPaneReaders) {
|
|
25
|
+
const choice = readers.awaitingChoice(lead.tmux_target, lead.command);
|
|
26
|
+
if (choice === true)
|
|
27
|
+
return "it is on a dialog";
|
|
28
|
+
if (choice === null)
|
|
29
|
+
return "its screen could not be read";
|
|
30
|
+
if (readers.holdsInput(lead.tmux_target, lead.command))
|
|
31
|
+
return "it has unsubmitted text in its input box";
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
export function evaluateLeadWatch(sub, mode, snapshot, readers) {
|
|
35
|
+
const lead = db
|
|
36
|
+
.prepare("SELECT id, name, status, tmux_target, tmux_socket, pane_pid, command FROM agents WHERE id = ? AND kind = ?")
|
|
37
|
+
.get(sub.agent_id, LEAD_KIND);
|
|
38
|
+
if (!lead || lead.status !== "running")
|
|
39
|
+
return { kind: "invalid", reason: LEAD_TARGET_GONE };
|
|
40
|
+
if (lead.pane_pid !== sub.pane_pid)
|
|
41
|
+
return { kind: "invalid", reason: LEAD_TARGET_RESTARTED };
|
|
42
|
+
if (snapshot === null)
|
|
43
|
+
return { kind: "pending", reason: "tmux did not answer" };
|
|
44
|
+
const probe = rowAliveProbe(lead.tmux_socket, lead.tmux_target, snapshot);
|
|
45
|
+
if (probe.live === null)
|
|
46
|
+
return { kind: "pending", reason: "its pane cannot be probed from this process" };
|
|
47
|
+
if (!probe.live)
|
|
48
|
+
return { kind: "invalid", reason: LEAD_TARGET_GONE };
|
|
49
|
+
if (paneReissued(sub.pane_pid, probe))
|
|
50
|
+
return { kind: "invalid", reason: LEAD_TARGET_REISSUED };
|
|
51
|
+
const state = readLeadTurnState(lead.id);
|
|
52
|
+
if (state === null || state.pane_pid !== sub.pane_pid)
|
|
53
|
+
return { kind: "pending", reason: "no turn recorded yet" };
|
|
54
|
+
let session = sub.session_id;
|
|
55
|
+
if (session === "" && state.state !== "unknown") {
|
|
56
|
+
session = state.session_id;
|
|
57
|
+
try {
|
|
58
|
+
db.prepare("UPDATE lead_idle_subscriptions SET session_id = ? WHERE wake_id = ? AND session_id = ''").run(session, sub.wake_id);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Unbound for another tick; the in-memory binding still judges this one.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (session !== "" && state.session_id !== session)
|
|
65
|
+
return { kind: "invalid", reason: LEAD_TARGET_RESTARTED };
|
|
66
|
+
const ended = mode === "any" ? state.idle_seq > sub.baseline_idle_seq : state.state === "idle";
|
|
67
|
+
if (!ended)
|
|
68
|
+
return { kind: "pending", reason: `its turn state is ${state.state}` };
|
|
69
|
+
const vetoed = paneVeto(lead, readers);
|
|
70
|
+
if (vetoed !== null)
|
|
71
|
+
return { kind: "pending", reason: `its turn ended, but ${vetoed}` };
|
|
72
|
+
return { kind: "idle", idle_seq: state.idle_seq };
|
|
73
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
import { listProjects } from "./context.js";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { parseTags } from "./result.js";
|
|
5
|
+
import { OPEN_BLOCKERS_SQL } from "./tools/todos.js";
|
|
6
|
+
import { readLeadTurnState } from "./leadState.js";
|
|
7
|
+
import { liveTargets, paneReissued, rowAlive, rowAliveProbe } from "./tmux.js";
|
|
8
|
+
export const PORTFOLIO_REASONS = [
|
|
9
|
+
"needs_human",
|
|
10
|
+
"dead_lead_pane",
|
|
11
|
+
"missing_root_with_work",
|
|
12
|
+
"worker_needs_input",
|
|
13
|
+
"wake_overdue_5m",
|
|
14
|
+
"in_progress_blocked",
|
|
15
|
+
"all_active_todos_blocked",
|
|
16
|
+
"stale_in_progress_48h",
|
|
17
|
+
"worker_working",
|
|
18
|
+
"todo_in_progress",
|
|
19
|
+
"wake_due_24h",
|
|
20
|
+
"quiet",
|
|
21
|
+
];
|
|
22
|
+
export const NEEDS_HUMAN_TAG = "needs-human";
|
|
23
|
+
const NEEDS_HUMAN_ITEM_CAP = 5;
|
|
24
|
+
const WAKE_OVERDUE_GRACE = "-5 minutes";
|
|
25
|
+
const STALE_IN_PROGRESS = "-48 hours";
|
|
26
|
+
const WAKE_UPCOMING = "+24 hours";
|
|
27
|
+
export const ACTIVE_WAKE_WHERE = "cancelled_at IS NULL AND (fired_at IS NULL OR repeat_every_ms IS NOT NULL)";
|
|
28
|
+
export function leadText(p) {
|
|
29
|
+
return p.lead.state === "alive" && p.lead.turn !== "unknown"
|
|
30
|
+
? `alive, turn ${p.lead.turn === "working" ? "working" : "ended"}`
|
|
31
|
+
: p.lead.state;
|
|
32
|
+
}
|
|
33
|
+
function leadTurnFor(agentId, panePid) {
|
|
34
|
+
const row = readLeadTurnState(agentId);
|
|
35
|
+
if (row === null || row.pane_pid !== panePid)
|
|
36
|
+
return "unknown";
|
|
37
|
+
return row.state === "working" ? "working" : row.state === "idle" ? "turn_ended" : "unknown";
|
|
38
|
+
}
|
|
39
|
+
const ONE = (sql, ...params) => db.prepare(sql).get(...params);
|
|
40
|
+
function shiftedClock(now, modifier) {
|
|
41
|
+
return ONE("SELECT datetime(?, ?) AS t", now, modifier).t;
|
|
42
|
+
}
|
|
43
|
+
function lastActivity(projectId, countWakeFires = true) {
|
|
44
|
+
const row = ONE(`SELECT MAX(t) AS t FROM (
|
|
45
|
+
SELECT created_at AS t FROM projects WHERE id = :pid
|
|
46
|
+
UNION ALL SELECT updated_at FROM todos WHERE project_id = :pid
|
|
47
|
+
UNION ALL SELECT c.created_at FROM todo_comments c JOIN todos t ON t.id = c.todo_id WHERE t.project_id = :pid
|
|
48
|
+
UNION ALL SELECT updated_at FROM pads WHERE project_id = :pid
|
|
49
|
+
UNION ALL SELECT created_at FROM agents WHERE project_id = :pid
|
|
50
|
+
UNION ALL SELECT state_changed_at FROM agents WHERE project_id = :pid
|
|
51
|
+
UNION ALL SELECT substr(l.created_at, 1, 19) FROM agent_state_log l
|
|
52
|
+
WHERE l.actor_id IN (SELECT actor_id FROM agents WHERE project_id = :pid)
|
|
53
|
+
UNION ALL SELECT created_at FROM wakes WHERE project_id = :pid
|
|
54
|
+
${countWakeFires ? "UNION ALL SELECT fired_at FROM wakes WHERE project_id = :pid" : ""}
|
|
55
|
+
)`, { pid: projectId });
|
|
56
|
+
return row.t;
|
|
57
|
+
}
|
|
58
|
+
function projectRow(project, now, snapshot) {
|
|
59
|
+
const rootExists = existsSync(project.path);
|
|
60
|
+
const todoRows = db
|
|
61
|
+
.prepare(`SELECT t.id, t.title, t.slug, t.priority, t.status, t.tags, t.updated_at,
|
|
62
|
+
EXISTS (${OPEN_BLOCKERS_SQL}) AS blocked
|
|
63
|
+
FROM todos t
|
|
64
|
+
WHERE t.project_id = ? AND t.status IN ('open', 'in_progress') AND t.archived_at IS NULL`)
|
|
65
|
+
.all(project.id);
|
|
66
|
+
const todos = { open: 0, in_progress: 0, blocked: 0, blocked_in_progress: 0, high: 0 };
|
|
67
|
+
const tagged = [];
|
|
68
|
+
for (const t of todoRows) {
|
|
69
|
+
if (t.status === "open")
|
|
70
|
+
todos.open++;
|
|
71
|
+
else
|
|
72
|
+
todos.in_progress++;
|
|
73
|
+
if (t.blocked) {
|
|
74
|
+
todos.blocked++;
|
|
75
|
+
if (t.status === "in_progress")
|
|
76
|
+
todos.blocked_in_progress++;
|
|
77
|
+
}
|
|
78
|
+
if (t.priority === "high")
|
|
79
|
+
todos.high++;
|
|
80
|
+
if (parseTags(t.tags).includes(NEEDS_HUMAN_TAG))
|
|
81
|
+
tagged.push(t);
|
|
82
|
+
}
|
|
83
|
+
tagged.sort((a, b) => (a.updated_at < b.updated_at ? -1 : a.updated_at > b.updated_at ? 1 : a.id - b.id));
|
|
84
|
+
const agents = db
|
|
85
|
+
.prepare("SELECT id, kind, agent_state, tmux_target, tmux_socket, pane_pid FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
|
|
86
|
+
.all(project.id);
|
|
87
|
+
const liveness = (a) => snapshot ? rowAlive(a.tmux_socket, a.tmux_target, snapshot) : null;
|
|
88
|
+
const reissued = (a) => snapshot !== null && paneReissued(a.pane_pid, rowAliveProbe(a.tmux_socket, a.tmux_target, snapshot));
|
|
89
|
+
const workers = { working: 0, idle: 0, needs_input: 0, other: 0, unreachable: 0, unconfirmed: 0 };
|
|
90
|
+
let liveWorking = 0;
|
|
91
|
+
let liveWaiting = 0;
|
|
92
|
+
let lead = { state: "none", agent_id: null, turn: "unknown" };
|
|
93
|
+
for (const a of agents) {
|
|
94
|
+
if (a.kind === "lead") {
|
|
95
|
+
const live = reissued(a) ? false : liveness(a);
|
|
96
|
+
lead = {
|
|
97
|
+
state: live === true ? "alive" : live === false ? "dead_pane" : "unknown",
|
|
98
|
+
agent_id: a.id,
|
|
99
|
+
turn: live === true ? leadTurnFor(a.id, a.pane_pid) : "unknown",
|
|
100
|
+
};
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (a.kind !== "agent")
|
|
104
|
+
continue;
|
|
105
|
+
const live = reissued(a) ? false : liveness(a);
|
|
106
|
+
if (live === false)
|
|
107
|
+
workers.unreachable++;
|
|
108
|
+
else if (live === null)
|
|
109
|
+
workers.unconfirmed++;
|
|
110
|
+
else if (a.agent_state === "working") {
|
|
111
|
+
workers.working++;
|
|
112
|
+
liveWorking++;
|
|
113
|
+
}
|
|
114
|
+
else if (a.agent_state === "idle")
|
|
115
|
+
workers.idle++;
|
|
116
|
+
else if (a.agent_state === "waiting") {
|
|
117
|
+
workers.needs_input++;
|
|
118
|
+
liveWaiting++;
|
|
119
|
+
}
|
|
120
|
+
else
|
|
121
|
+
workers.other++;
|
|
122
|
+
}
|
|
123
|
+
const overdueBefore = shiftedClock(now, WAKE_OVERDUE_GRACE);
|
|
124
|
+
const upcomingUntil = shiftedClock(now, WAKE_UPCOMING);
|
|
125
|
+
const wakeCounts = db
|
|
126
|
+
.prepare(`SELECT COUNT(*) AS pending,
|
|
127
|
+
COALESCE(SUM(held_at IS NULL AND COALESCE(due_at, max_wait_at) < ?), 0) AS overdue,
|
|
128
|
+
COALESCE(SUM(held_at IS NULL AND COALESCE(due_at, max_wait_at) <= ?), 0) AS overdue_grace,
|
|
129
|
+
COALESCE(SUM(COALESCE(due_at, max_wait_at) > ? AND COALESCE(due_at, max_wait_at) <= ?), 0) AS upcoming
|
|
130
|
+
FROM wakes WHERE project_id = ? AND ${ACTIVE_WAKE_WHERE}`)
|
|
131
|
+
.get(now, overdueBefore, overdueBefore, upcomingUntil, project.id);
|
|
132
|
+
const lastActivityAt = lastActivity(project.id);
|
|
133
|
+
const staleClock = lastActivity(project.id, false);
|
|
134
|
+
const staleBefore = shiftedClock(now, STALE_IN_PROGRESS);
|
|
135
|
+
const activeTodos = todos.open + todos.in_progress;
|
|
136
|
+
const found = new Set();
|
|
137
|
+
if (tagged.length > 0)
|
|
138
|
+
found.add("needs_human");
|
|
139
|
+
if (lead.state === "dead_pane" && (todos.in_progress > 0 || wakeCounts.pending > 0))
|
|
140
|
+
found.add("dead_lead_pane");
|
|
141
|
+
if (!rootExists && (activeTodos > 0 || wakeCounts.pending > 0))
|
|
142
|
+
found.add("missing_root_with_work");
|
|
143
|
+
if (liveWaiting > 0)
|
|
144
|
+
found.add("worker_needs_input");
|
|
145
|
+
if (wakeCounts.overdue_grace > 0)
|
|
146
|
+
found.add("wake_overdue_5m");
|
|
147
|
+
if (todos.blocked_in_progress > 0)
|
|
148
|
+
found.add("in_progress_blocked");
|
|
149
|
+
if (activeTodos > 0 && todos.blocked === activeTodos)
|
|
150
|
+
found.add("all_active_todos_blocked");
|
|
151
|
+
if (todos.in_progress > 0 && liveWorking === 0 && staleClock <= staleBefore)
|
|
152
|
+
found.add("stale_in_progress_48h");
|
|
153
|
+
if (liveWorking > 0)
|
|
154
|
+
found.add("worker_working");
|
|
155
|
+
if (todos.in_progress > 0)
|
|
156
|
+
found.add("todo_in_progress");
|
|
157
|
+
if (wakeCounts.upcoming > 0)
|
|
158
|
+
found.add("wake_due_24h");
|
|
159
|
+
const HARD_STALL = [
|
|
160
|
+
"dead_lead_pane",
|
|
161
|
+
"missing_root_with_work",
|
|
162
|
+
"worker_needs_input",
|
|
163
|
+
"wake_overdue_5m",
|
|
164
|
+
];
|
|
165
|
+
const TODO_GRAPH = ["in_progress_blocked", "all_active_todos_blocked", "stale_in_progress_48h"];
|
|
166
|
+
const freshInProgress = todos.in_progress > todos.blocked_in_progress && !found.has("stale_in_progress_48h");
|
|
167
|
+
const movingSignal = liveWorking > 0 || freshInProgress;
|
|
168
|
+
let lane;
|
|
169
|
+
if (HARD_STALL.some((r) => found.has(r)))
|
|
170
|
+
lane = "stuck";
|
|
171
|
+
else if (movingSignal)
|
|
172
|
+
lane = "moving";
|
|
173
|
+
else if (found.has("needs_human"))
|
|
174
|
+
lane = "waiting_on_you";
|
|
175
|
+
else if (TODO_GRAPH.some((r) => found.has(r)))
|
|
176
|
+
lane = "stuck";
|
|
177
|
+
else {
|
|
178
|
+
lane = "quiet";
|
|
179
|
+
found.add("quiet");
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
id: project.id,
|
|
183
|
+
name: project.name,
|
|
184
|
+
root: project.path,
|
|
185
|
+
root_exists: rootExists,
|
|
186
|
+
lane,
|
|
187
|
+
reasons: PORTFOLIO_REASONS.filter((r) => found.has(r)),
|
|
188
|
+
lead,
|
|
189
|
+
workers,
|
|
190
|
+
todos,
|
|
191
|
+
needs_human: tagged.length,
|
|
192
|
+
needs_human_items: tagged.slice(0, NEEDS_HUMAN_ITEM_CAP).map((t) => ({
|
|
193
|
+
todo_id: t.id,
|
|
194
|
+
title: t.title,
|
|
195
|
+
slug: t.slug === "" ? null : t.slug,
|
|
196
|
+
updated_at: t.updated_at,
|
|
197
|
+
})),
|
|
198
|
+
last_activity_at: lastActivityAt,
|
|
199
|
+
wakes: { pending: wakeCounts.pending, overdue: wakeCounts.overdue },
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
export function collectPortfolio(now) {
|
|
203
|
+
const asOf = now ?? ONE("SELECT datetime('now') AS now").now;
|
|
204
|
+
const snapshot = liveTargets();
|
|
205
|
+
const projects = listProjects().map((p) => projectRow(p, asOf, snapshot));
|
|
206
|
+
const lanes = { waiting_on_you: 0, stuck: 0, moving: 0, quiet: 0 };
|
|
207
|
+
for (const p of projects)
|
|
208
|
+
lanes[p.lane]++;
|
|
209
|
+
return { schema_version: 1, as_of: asOf, totals: { projects: projects.length, lanes }, projects };
|
|
210
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { backupNow } from "./backup.js";
|
|
2
|
+
import { dataDir, db } from "./db.js";
|
|
3
|
+
export const PROJECT_OWNER_TABLES = [
|
|
4
|
+
"pads",
|
|
5
|
+
"todos",
|
|
6
|
+
"kv",
|
|
7
|
+
"leases",
|
|
8
|
+
"agents",
|
|
9
|
+
"wakes",
|
|
10
|
+
"command_trust",
|
|
11
|
+
];
|
|
12
|
+
const COUNTED_TABLES = [
|
|
13
|
+
...PROJECT_OWNER_TABLES.map((t) => [t, `SELECT COUNT(*) AS n FROM ${t} WHERE project_id = ?`]),
|
|
14
|
+
["todo_comments", "SELECT COUNT(*) AS n FROM todo_comments WHERE todo_id IN (SELECT id FROM todos WHERE project_id = ?)"],
|
|
15
|
+
["agent_messages", "SELECT COUNT(*) AS n FROM agent_messages WHERE project_id = ?"],
|
|
16
|
+
];
|
|
17
|
+
export function projectRowCounts(projectId) {
|
|
18
|
+
const counts = {};
|
|
19
|
+
for (const [table, sql] of COUNTED_TABLES) {
|
|
20
|
+
const n = db.prepare(sql).get(projectId).n;
|
|
21
|
+
if (n > 0)
|
|
22
|
+
counts[table] = n;
|
|
23
|
+
}
|
|
24
|
+
return counts;
|
|
25
|
+
}
|
|
26
|
+
export function formatRowCounts(counts) {
|
|
27
|
+
return Object.entries(counts)
|
|
28
|
+
.map(([table, n]) => `${table}: ${n}`)
|
|
29
|
+
.join(", ");
|
|
30
|
+
}
|
|
31
|
+
function assertNoRunningAgents(projectId, name) {
|
|
32
|
+
const running = db
|
|
33
|
+
.prepare("SELECT id, name FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
|
|
34
|
+
.all(projectId);
|
|
35
|
+
if (running.length === 0)
|
|
36
|
+
return;
|
|
37
|
+
const names = running.map((a) => `${a.name} (agent ${a.id})`).join(", ");
|
|
38
|
+
throw new Error(`project ${projectId} ("${name}") has running agents: ${names}. Nothing deleted. Run hive doctor to close rows whose panes are gone, or agent_close them.`);
|
|
39
|
+
}
|
|
40
|
+
const deleteProjectRows = db.transaction((projectId, name, expected, after) => {
|
|
41
|
+
assertNoRunningAgents(projectId, name);
|
|
42
|
+
if (JSON.stringify(projectRowCounts(projectId)) !== JSON.stringify(expected)) {
|
|
43
|
+
throw new Error(`project ${projectId} ("${name}") changed while the snapshot was taken; nothing removed, run it again.`);
|
|
44
|
+
}
|
|
45
|
+
db.prepare("DELETE FROM agent_messages WHERE project_id = ?").run(projectId);
|
|
46
|
+
if (db.prepare("DELETE FROM projects WHERE id = ?").run(projectId).changes === 0) {
|
|
47
|
+
throw new Error(`no project ${projectId}.`);
|
|
48
|
+
}
|
|
49
|
+
after();
|
|
50
|
+
});
|
|
51
|
+
export function removeProject(projectId, opts) {
|
|
52
|
+
const target = db.prepare("SELECT id, name, path FROM projects WHERE id = ?").get(projectId);
|
|
53
|
+
if (!target)
|
|
54
|
+
throw new Error(`no project ${projectId}.`);
|
|
55
|
+
assertNoRunningAgents(projectId, target.name);
|
|
56
|
+
const counts = projectRowCounts(projectId);
|
|
57
|
+
let snapshot = null;
|
|
58
|
+
if (opts.snapshot) {
|
|
59
|
+
const result = backupNow(db, dataDir, "manual");
|
|
60
|
+
if (!result.ok)
|
|
61
|
+
throw new Error(`could not snapshot the store first, nothing deleted: ${result.error ?? "unknown error"}`);
|
|
62
|
+
snapshot = result.path ?? null;
|
|
63
|
+
}
|
|
64
|
+
opts.afterSnapshot?.();
|
|
65
|
+
const removed = { deleted: { id: target.id, name: target.name, path: target.path }, counts, snapshot };
|
|
66
|
+
deleteProjectRows.immediate(projectId, target.name, counts, () => opts.onRemoved?.(removed));
|
|
67
|
+
return removed;
|
|
68
|
+
}
|
package/dist/projectYml.js
CHANGED
|
@@ -14,6 +14,7 @@ export const BUILT_IN_PROJECT_YML = Object.freeze({
|
|
|
14
14
|
profile: null,
|
|
15
15
|
agents: null,
|
|
16
16
|
lead_branches: null,
|
|
17
|
+
first_message: null,
|
|
17
18
|
context_checkpoint_percent: null,
|
|
18
19
|
lead_turn_budget: null,
|
|
19
20
|
dashboard: false,
|
|
@@ -55,6 +56,15 @@ export function mergedProjectVars(config) {
|
|
|
55
56
|
delete safe[key];
|
|
56
57
|
return { ...safe, ...agentVars(config) };
|
|
57
58
|
}
|
|
59
|
+
// Absent sends nothing; "" (or whitespace only) also sends nothing, and a project one beats a global message.
|
|
60
|
+
export function resolveFirstMessage(config, sources = {}) {
|
|
61
|
+
const value = config?.first_message;
|
|
62
|
+
if (value == null)
|
|
63
|
+
return { message: "", source: "unset" };
|
|
64
|
+
if (value.trim() === "")
|
|
65
|
+
return { message: "", source: "empty" };
|
|
66
|
+
return { message: value, source: sources.first_message === "global" ? "global" : "project" };
|
|
67
|
+
}
|
|
58
68
|
function isMapping(value) {
|
|
59
69
|
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
60
70
|
return false;
|
|
@@ -225,6 +235,15 @@ function applyLayer(root, path, source, config, sources, warnings) {
|
|
|
225
235
|
else
|
|
226
236
|
warn("lead_branches must be a list of branch names; ignoring it.");
|
|
227
237
|
}
|
|
238
|
+
if (has("first_message")) {
|
|
239
|
+
const value = root.first_message;
|
|
240
|
+
if (value == null || typeof value === "string") {
|
|
241
|
+
config.first_message = value ?? "";
|
|
242
|
+
sources.first_message = source;
|
|
243
|
+
}
|
|
244
|
+
else
|
|
245
|
+
warn("first_message must be a string; ignoring it.");
|
|
246
|
+
}
|
|
228
247
|
if (has("vars")) {
|
|
229
248
|
const value = root.vars;
|
|
230
249
|
if (value == null) {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { captureQueenWriteIdentity, effectiveProjectId, isQueenLead } from "./context.js";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { cutToUnitBudget, flatten } from "./slug.js";
|
|
4
|
+
export const QUEEN_AUDIT_SUMMARY_MAX = 160;
|
|
5
|
+
export const QUEEN_AUDIT_DEFAULT_LIMIT = 20;
|
|
6
|
+
export const QUEEN_AUDIT_MAX_LIMIT = 100;
|
|
7
|
+
export const QUEEN_AUDIT_RETENTION = "-30 days";
|
|
8
|
+
export const QUEEN_AUDIT_MAX_ROWS = 20_000;
|
|
9
|
+
function resource(type, value) {
|
|
10
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? { type, id: value } : null;
|
|
11
|
+
}
|
|
12
|
+
function removalSummary(r) {
|
|
13
|
+
const counts = Object.entries((r.counts ?? {})).map(([t, n]) => `${t} ${n}`);
|
|
14
|
+
return `removed project "${r.name}"${counts.length > 0 ? ` with ${counts.join(", ")}` : ""}`;
|
|
15
|
+
}
|
|
16
|
+
export const QUEEN_AUDIT_OPERATIONS = {
|
|
17
|
+
todo_create: {
|
|
18
|
+
mode: "database",
|
|
19
|
+
resource: (r) => resource("todo", r.todo_id),
|
|
20
|
+
summary: (a) => String(a.title),
|
|
21
|
+
},
|
|
22
|
+
todo_comment: {
|
|
23
|
+
mode: "database",
|
|
24
|
+
resource: (r) => resource("todo_comment", r.comment_id),
|
|
25
|
+
summary: (a) => `todo #${a.todo_id}: ${a.body}`,
|
|
26
|
+
},
|
|
27
|
+
wake_set: {
|
|
28
|
+
mode: "database",
|
|
29
|
+
resource: (r) => resource("wake", r.wake_id),
|
|
30
|
+
summary: (a, r) => `to ${r.deliver_to}: ${a.body}`,
|
|
31
|
+
},
|
|
32
|
+
wake_when_idle: {
|
|
33
|
+
mode: "database",
|
|
34
|
+
resource: (r) => r.status === "already_satisfied" ? null : resource("wake", r.wake_id),
|
|
35
|
+
summary: (a, r) => `${a.mode ?? "any"} to ${r.deliver_to}: ${a.body}`,
|
|
36
|
+
},
|
|
37
|
+
wake_update: {
|
|
38
|
+
mode: "database",
|
|
39
|
+
resource: (r) => r.updated === true ? resource("wake", r.wake_id) : null,
|
|
40
|
+
summary: (a) => `changed ${["body", "delay_seconds", "repeat_every_seconds"].filter((key) => a[key] !== undefined).join(", ")}${a.body === undefined ? "" : `: ${a.body}`}`,
|
|
41
|
+
},
|
|
42
|
+
wake_cancel: {
|
|
43
|
+
mode: "database",
|
|
44
|
+
resource: (r) => r.cancelled === true ? resource("wake", r.wake_id) : null,
|
|
45
|
+
summary: (_a, r) => `cancelled; notices ${r.cancelled_notices ?? 0}`,
|
|
46
|
+
},
|
|
47
|
+
agent_send: {
|
|
48
|
+
mode: "external",
|
|
49
|
+
resource: (r) => r.sent === true ? resource("agent", r.agent_id) : null,
|
|
50
|
+
summary: (a, r) => `agent #${r.agent_id} ${a.submit === false ? "pasted without submit" : "submitted"}: ${a.text}`,
|
|
51
|
+
},
|
|
52
|
+
project_prune: {
|
|
53
|
+
mode: "database",
|
|
54
|
+
resource: (r) => resource("project", r.project_id),
|
|
55
|
+
summary: (_a, r) => removalSummary(r),
|
|
56
|
+
},
|
|
57
|
+
"hive project rm": {
|
|
58
|
+
mode: "database",
|
|
59
|
+
resource: (r) => resource("project", r.project_id),
|
|
60
|
+
summary: (_a, r) => removalSummary(r),
|
|
61
|
+
},
|
|
62
|
+
"hive lead": {
|
|
63
|
+
mode: "external",
|
|
64
|
+
resource: (r) => r.disposition === "started" || r.disposition === "adopted" ? resource("agent", r.agent_id) : null,
|
|
65
|
+
summary: (a, r) => `${r.disposition} lead #${r.agent_id}${a.detach ? " detached" : ""}`,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
function descriptor(operation) {
|
|
69
|
+
const found = Object.hasOwn(QUEEN_AUDIT_OPERATIONS, operation) ? QUEEN_AUDIT_OPERATIONS[operation] : undefined;
|
|
70
|
+
if (!found)
|
|
71
|
+
throw new Error(`[queen_audit:missing-coverage] ${operation} has no audit descriptor`);
|
|
72
|
+
return found;
|
|
73
|
+
}
|
|
74
|
+
function insertAudit(operation, targetProjectId, args, receipt, identity) {
|
|
75
|
+
const spec = descriptor(operation);
|
|
76
|
+
const touched = spec.resource(receipt);
|
|
77
|
+
if (!touched)
|
|
78
|
+
return;
|
|
79
|
+
const text = flatten(spec.summary(args, receipt));
|
|
80
|
+
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);
|
|
83
|
+
}
|
|
84
|
+
export function commitQueenStateWrite(operation, targetProjectId, args, mutate) {
|
|
85
|
+
const identity = captureQueenWriteIdentity(targetProjectId);
|
|
86
|
+
if (!identity)
|
|
87
|
+
return mutate();
|
|
88
|
+
const spec = descriptor(operation);
|
|
89
|
+
if (spec.mode !== "database")
|
|
90
|
+
throw new Error(`[queen_audit:wrong-mode] ${operation} is not a database write`);
|
|
91
|
+
return db.transaction(() => {
|
|
92
|
+
const receipt = mutate();
|
|
93
|
+
insertAudit(operation, targetProjectId, args, receipt, identity);
|
|
94
|
+
return receipt;
|
|
95
|
+
}).immediate();
|
|
96
|
+
}
|
|
97
|
+
export function confirmQueenWrite(operation, targetProjectId, args, receipt, identity = captureQueenWriteIdentity(targetProjectId)) {
|
|
98
|
+
if (!identity)
|
|
99
|
+
return;
|
|
100
|
+
try {
|
|
101
|
+
insertAudit(operation, targetProjectId, args, receipt, identity);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
if (operation !== "agent_send")
|
|
105
|
+
throw e;
|
|
106
|
+
throw new Error(`[queen_audit:write-completed-audit-failed] agent #${receipt.agent_id}: the text already landed. Do not resend blindly. ${e instanceof Error ? e.message : String(e)}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function positiveInteger(value, name) {
|
|
110
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
111
|
+
throw new Error(`${name} must be a positive integer`);
|
|
112
|
+
}
|
|
113
|
+
export function recentQueenAudit(limit) {
|
|
114
|
+
return db.prepare("SELECT * FROM queen_audit ORDER BY id DESC LIMIT ?").all(limit);
|
|
115
|
+
}
|
|
116
|
+
export function listQueenAudit(options = {}, homeProjectId) {
|
|
117
|
+
const requestedLimit = options.limit ?? QUEEN_AUDIT_DEFAULT_LIMIT;
|
|
118
|
+
positiveInteger(requestedLimit, "limit");
|
|
119
|
+
if (options.project_id !== undefined)
|
|
120
|
+
positiveInteger(options.project_id, "project_id");
|
|
121
|
+
const target = options.project_id === undefined ? null : effectiveProjectId(options.project_id);
|
|
122
|
+
const queen = isQueenLead();
|
|
123
|
+
const home = queen ? null : homeProjectId ?? effectiveProjectId();
|
|
124
|
+
if (!queen && target !== null && target !== home)
|
|
125
|
+
throw new Error("Queen audit access is limited to your own project");
|
|
126
|
+
const projectId = target ?? home;
|
|
127
|
+
const limit = Math.min(requestedLimit, QUEEN_AUDIT_MAX_LIMIT);
|
|
128
|
+
const entries = (projectId === null
|
|
129
|
+
? db.prepare("SELECT * FROM queen_audit ORDER BY id DESC LIMIT ?").all(limit)
|
|
130
|
+
: db.prepare("SELECT * FROM queen_audit WHERE target_project_id = ? ORDER BY id DESC LIMIT ?").all(projectId, limit));
|
|
131
|
+
return { entries, limit, project_id: projectId };
|
|
132
|
+
}
|