@cmgmyr/hive 1.9.1 → 1.10.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/dist/crew.js ADDED
@@ -0,0 +1,507 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { db } from "./db.js";
3
+ import { isLinkedWorktree } from "./context.js";
4
+ import { awaitingFirstPrompt } from "./firstPrompt.js";
5
+ import { heldReasonLabel } from "./heldLabel.js";
6
+ import { harnessFor, paneClassifierFor } from "./harnesses.js";
7
+ import { NEEDS_HUMAN_TAG } from "./portfolio.js";
8
+ import { loadProjectYml } from "./projectYml.js";
9
+ import { parseTags } from "./result.js";
10
+ import { ACTIVE_TIMER_WHERE } from "./scheduler.js";
11
+ import { reportsAgentStateLog } from "./stateProvenance.js";
12
+ import { liveTargets, rowOwnership } from "./tmux.js";
13
+ import { SKILL_TOOL, lastComponent, readContextFill, readRecentToolCalls, } from "./transcript.js";
14
+ export function crewStoreProblem(database = db) {
15
+ const tables = new Set(database.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((r) => r.name));
16
+ for (const need of ["projects", "agents", "todos", "wakes"]) {
17
+ if (!tables.has(need))
18
+ return `the store has no ${need} table`;
19
+ }
20
+ const columns = database.prepare("SELECT name FROM pragma_table_info('agents')").all();
21
+ if (!columns.some((c) => c.name === "todo_id"))
22
+ return "the store predates the worker-to-todo link (agents.todo_id)";
23
+ return null;
24
+ }
25
+ function isoUtc(value) {
26
+ if (!value)
27
+ return null;
28
+ const date = new Date(/(Z|[+-]\d\d:?\d\d)$/.test(value) ? value : `${value.replace(" ", "T")}Z`);
29
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
30
+ }
31
+ const WAKE_LABEL_MAX = 60;
32
+ function wakeLabel(body) {
33
+ const line = body.split("\n").map((l) => l.trim()).find((l) => l !== "") ?? "";
34
+ return line.slice(0, WAKE_LABEL_MAX);
35
+ }
36
+ // Worker ids named by pending one-shot idle wakes that report to the lead; notices (parent set) watch nothing.
37
+ function oneShotWatched(pending) {
38
+ return pending
39
+ .filter((w) => w.kind !== "delay" && w.parent_wake_id === null && w.deliver_actor.startsWith("lead:"))
40
+ .flatMap((w) => {
41
+ try {
42
+ const ids = JSON.parse(w.watch);
43
+ return Array.isArray(ids) ? ids.filter((x) => typeof x === "number") : [];
44
+ }
45
+ catch {
46
+ return [];
47
+ }
48
+ });
49
+ }
50
+ function stripped(env) {
51
+ const { GIT_DIR: _gitDir, GIT_COMMON_DIR: _gitCommonDir, GIT_WORK_TREE: _gitWorkTree, ...rest } = env;
52
+ return rest;
53
+ }
54
+ function git(cwd, args) {
55
+ try {
56
+ return execFileSync("git", args, {
57
+ cwd,
58
+ encoding: "utf8",
59
+ stdio: ["ignore", "pipe", "ignore"],
60
+ timeout: 2000,
61
+ env: stripped(process.env),
62
+ }).trim();
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ export function readCommitsAhead(cwd, projectRoot) {
69
+ if (!cwd || cwd === projectRoot || !isLinkedWorktree(cwd))
70
+ return null;
71
+ const candidates = [];
72
+ const remoteHead = git(cwd, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
73
+ if (remoteHead)
74
+ candidates.push(remoteHead);
75
+ candidates.push("refs/heads/main", "refs/heads/master");
76
+ for (const ref of candidates) {
77
+ if (git(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]) === null)
78
+ continue;
79
+ const count = git(cwd, ["rev-list", "--count", `${ref}..HEAD`]);
80
+ return count !== null && /^\d+$/.test(count) ? Number(count) : null;
81
+ }
82
+ return null;
83
+ }
84
+ export function reviewSkillsOf(vars) {
85
+ const value = vars.review_skills;
86
+ return typeof value === "string" ? value.split(",").map((name) => name.trim()).filter((name) => name !== "") : [];
87
+ }
88
+ function heredocDelimiter(command, at) {
89
+ let i = at + 2;
90
+ if (command[i] === "<")
91
+ return null;
92
+ const stripTabs = command[i] === "-";
93
+ if (stripTabs)
94
+ i++;
95
+ while (command[i] === " " || command[i] === "\t")
96
+ i++;
97
+ const quote = command[i] === "'" || command[i] === '"' ? command[i] : "";
98
+ if (quote)
99
+ i++;
100
+ const start = i;
101
+ while (i < command.length && (quote ? command[i] !== quote : /[\w.-]/.test(command[i])))
102
+ i++;
103
+ if (i === start)
104
+ return null;
105
+ return { delimiter: command.slice(start, i), stripTabs, end: quote ? i + 1 : i };
106
+ }
107
+ function skipHeredocBody(command, from, heredoc) {
108
+ let i = from;
109
+ while (i < command.length) {
110
+ const eol = command.indexOf("\n", i);
111
+ const line = command.slice(i, eol === -1 ? command.length : eol);
112
+ i = eol === -1 ? command.length : eol + 1;
113
+ if ((heredoc.stripTabs ? line.replace(/^\t+/, "") : line) === heredoc.delimiter)
114
+ break;
115
+ }
116
+ return i;
117
+ }
118
+ function splitSegments(command) {
119
+ const segments = [];
120
+ const pending = [];
121
+ let current = "";
122
+ let quote = null;
123
+ for (let i = 0; i < command.length; i++) {
124
+ const ch = command[i];
125
+ if (quote) {
126
+ current += ch;
127
+ if (ch === "\\" && quote === '"' && i + 1 < command.length)
128
+ current += command[++i];
129
+ else if (ch === quote)
130
+ quote = null;
131
+ }
132
+ else if (ch === "\\" && i + 1 < command.length) {
133
+ current += ch + command[++i];
134
+ }
135
+ else if (ch === "'" || ch === '"') {
136
+ quote = ch;
137
+ current += ch;
138
+ }
139
+ else if (ch === "<" && command[i + 1] === "<") {
140
+ const heredoc = heredocDelimiter(command, i);
141
+ if (heredoc) {
142
+ pending.push(heredoc);
143
+ current += command.slice(i, heredoc.end);
144
+ i = heredoc.end - 1;
145
+ }
146
+ else {
147
+ current += ch;
148
+ }
149
+ }
150
+ else if (ch === ";" || ch === "\n" || (ch === "&" && command[i + 1] === "&")) {
151
+ if (ch === "&")
152
+ i++;
153
+ segments.push(current);
154
+ current = "";
155
+ if (ch === "\n") {
156
+ for (const heredoc of pending.splice(0))
157
+ i = skipHeredocBody(command, i + 1, heredoc) - 1;
158
+ }
159
+ }
160
+ else {
161
+ current += ch;
162
+ }
163
+ }
164
+ segments.push(current);
165
+ return segments;
166
+ }
167
+ function writesToFile(segment) {
168
+ let quote = null;
169
+ for (let i = 0; i < segment.length; i++) {
170
+ const ch = segment[i];
171
+ if (quote) {
172
+ if (ch === "\\" && quote === '"')
173
+ i++;
174
+ else if (ch === quote)
175
+ quote = null;
176
+ }
177
+ else if (ch === "\\") {
178
+ i++;
179
+ }
180
+ else if (ch === "'" || ch === '"') {
181
+ quote = ch;
182
+ }
183
+ else if (ch === ">") {
184
+ let j = i + 1;
185
+ if (segment[j] === ">")
186
+ j++;
187
+ if (segment[j] === "&")
188
+ continue;
189
+ while (segment[j] === " " || segment[j] === "\t")
190
+ j++;
191
+ const target = /^\S*/.exec(segment.slice(j))?.[0] ?? "";
192
+ if (target !== "/dev/null")
193
+ return true;
194
+ i = j + target.length - 1;
195
+ }
196
+ }
197
+ return false;
198
+ }
199
+ function tokenize(segment) {
200
+ const tokens = [];
201
+ let current = "";
202
+ let open = false;
203
+ let quote = null;
204
+ for (let i = 0; i < segment.length; i++) {
205
+ const ch = segment[i];
206
+ if (quote) {
207
+ if (ch === quote)
208
+ quote = null;
209
+ else if (ch === "\\" && quote === '"' && i + 1 < segment.length)
210
+ current += segment[++i];
211
+ else
212
+ current += ch;
213
+ }
214
+ else if (ch === "'" || ch === '"') {
215
+ quote = ch;
216
+ open = true;
217
+ }
218
+ else if (ch === "\\" && i + 1 < segment.length) {
219
+ current += segment[++i];
220
+ open = true;
221
+ }
222
+ else if (/\s/.test(ch)) {
223
+ if (open)
224
+ tokens.push(current);
225
+ current = "";
226
+ open = false;
227
+ }
228
+ else {
229
+ current += ch;
230
+ open = true;
231
+ }
232
+ }
233
+ if (open)
234
+ tokens.push(current);
235
+ return tokens;
236
+ }
237
+ const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
238
+ function toSegment(segment) {
239
+ const tokens = tokenize(segment);
240
+ let start = 0;
241
+ while (start < tokens.length && ENV_ASSIGNMENT.test(tokens[start]))
242
+ start++;
243
+ const rest = tokens.slice(start);
244
+ return { tokens: rest[0] === "cd" ? [] : rest, writes: writesToFile(segment) };
245
+ }
246
+ function segmentsOf(command) {
247
+ return splitSegments(command).map(toSegment).filter((s) => s.tokens.length > 0);
248
+ }
249
+ const basename = (token) => token.split("/").pop() ?? token;
250
+ const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
251
+ function matcherFor(label, tokens, wildcard) {
252
+ const pattern = (token) => wildcard ? token.split("<file>").map(escapeRegex).join(".+") : escapeRegex(token);
253
+ return { label, tokens: tokens.map((t) => new RegExp(`^${pattern(t)}$`)) };
254
+ }
255
+ function configuredMatchers(vars) {
256
+ const matchers = [];
257
+ const add = (key, label, onlyLast) => {
258
+ const value = vars[key];
259
+ if (typeof value !== "string" || value.trim() === "")
260
+ return;
261
+ const segments = segmentsOf(value);
262
+ for (const { tokens } of onlyLast ? segments.slice(-1) : segments)
263
+ matchers.push(matcherFor(label, tokens, key === "test_one"));
264
+ };
265
+ add("test_one", "testing", true);
266
+ add("test_all", "testing full suite", true);
267
+ add("check", "building", false);
268
+ add("install", "installing", false);
269
+ return matchers.sort((a, b) => b.tokens.length - a.tokens.length);
270
+ }
271
+ function matchesPrefix(tokens, matcher) {
272
+ return tokens.length >= matcher.tokens.length && matcher.tokens.every((re, i) => re.test(tokens[i]));
273
+ }
274
+ const READING_EXES = new Set(["rg", "grep", "cat", "head", "tail", "sed"]);
275
+ const FALLBACK_SKIP = new Set(["printf", "echo", "true", ":"]);
276
+ const NODE_RUNNERS = new Set(["npm", "pnpm", "yarn"]);
277
+ const TEST_BINARIES = new Set(["pest", "phpunit", "vitest", "jest"]);
278
+ const BUILD_BINARIES = new Set(["pint", "phpstan"]);
279
+ function unwrap(tokens) {
280
+ if (tokens[0] === "npx") {
281
+ let i = 1;
282
+ while (i < tokens.length && tokens[i].startsWith("-"))
283
+ i++;
284
+ return tokens.slice(i);
285
+ }
286
+ if ((tokens[0] === "pnpm" || tokens[0] === "yarn") && tokens[1] === "exec")
287
+ return tokens.slice(2);
288
+ if (tokens[0] === "php" && tokens[1] && (TEST_BINARIES.has(basename(tokens[1])) || BUILD_BINARIES.has(basename(tokens[1])))) {
289
+ return tokens.slice(1);
290
+ }
291
+ return tokens;
292
+ }
293
+ function genericLabel(tokens) {
294
+ const exe = basename(tokens[0]);
295
+ const [, a, b] = tokens;
296
+ if (exe === "git") {
297
+ if (a === "status" || a === "diff" || a === "log" || a === "show")
298
+ return "reading";
299
+ return a === "commit" ? "committing" : null;
300
+ }
301
+ if (READING_EXES.has(exe))
302
+ return exe === "sed" && tokens.some((t) => /^(-i|--in-place)/.test(t)) ? null : "reading";
303
+ if (NODE_RUNNERS.has(exe)) {
304
+ if (a === "test" || (a === "run" && b === "test"))
305
+ return "testing";
306
+ if (a === "run" && (b === "check" || b === "build"))
307
+ return "building";
308
+ return a === "install" || a === "ci" ? "installing" : null;
309
+ }
310
+ if (exe === "task") {
311
+ if (a === "test" || a?.startsWith("test:"))
312
+ return "testing";
313
+ return a === "check" || a === "build" ? "building" : null;
314
+ }
315
+ if (exe === "php" && a === "artisan" && b === "test")
316
+ return "testing";
317
+ if (exe === "node" && tokens.slice(1).includes("--test"))
318
+ return "testing";
319
+ if (TEST_BINARIES.has(exe))
320
+ return "testing";
321
+ if (BUILD_BINARIES.has(exe))
322
+ return "building";
323
+ if (exe === "composer") {
324
+ if (a === "check")
325
+ return "building";
326
+ return a === "install" || a === "update" ? "installing" : null;
327
+ }
328
+ return null;
329
+ }
330
+ function segmentLabel({ tokens, writes }, matchers) {
331
+ for (const matcher of matchers)
332
+ if (matchesPrefix(tokens, matcher))
333
+ return matcher.label;
334
+ const direct = genericLabel(tokens);
335
+ if (direct)
336
+ return direct === "reading" && writes ? "editing" : direct;
337
+ const unwrapped = unwrap(tokens);
338
+ return unwrapped !== tokens && unwrapped.length > 0 ? genericLabel(unwrapped) : null;
339
+ }
340
+ function commandLabel(command, matchers) {
341
+ const segments = segmentsOf(command);
342
+ let recognized = null;
343
+ for (const segment of segments)
344
+ recognized = segmentLabel(segment, matchers) ?? recognized;
345
+ if (recognized)
346
+ return recognized;
347
+ const fallback = segments.find((segment) => !FALLBACK_SKIP.has(basename(segment.tokens[0]))) ?? segments[0];
348
+ return fallback ? `running ${basename(fallback.tokens[0])}` : "running shell";
349
+ }
350
+ const READ_TOOLS = new Set(["Read", "Glob", "Grep", "read_file", "view_image"]);
351
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "apply_patch"]);
352
+ function callLabel(call, matchers, reviewSkills) {
353
+ const { name, input } = call;
354
+ if (READ_TOOLS.has(name))
355
+ return "reading";
356
+ if (EDIT_TOOLS.has(name))
357
+ return "editing";
358
+ if (name === SKILL_TOOL)
359
+ return typeof input.skill === "string" && reviewSkills.includes(input.skill) ? "reviewing" : `running ${SKILL_TOOL}`;
360
+ if ((name === "Agent" || name === "Task") && typeof input.subagent_type === "string" && /review/i.test(input.subagent_type)) {
361
+ return "reviewing";
362
+ }
363
+ if (name === "Bash")
364
+ return typeof input.command === "string" ? commandLabel(input.command, matchers) : "running shell";
365
+ if (/_(get|read|list)$/.test(name))
366
+ return "reading";
367
+ return `running ${lastComponent(name)}`;
368
+ }
369
+ export function classifyActivity(calls, vars) {
370
+ if (calls.length === 0)
371
+ return { label: "", since: null, lower_bound: false };
372
+ const matchers = configuredMatchers(vars);
373
+ const reviewSkills = reviewSkillsOf(vars);
374
+ const labels = [];
375
+ let reviewing = false;
376
+ for (const call of calls) {
377
+ let label = callLabel(call, matchers, reviewSkills);
378
+ if (label === "reviewing")
379
+ reviewing = true;
380
+ else if (label === "reading" && reviewing)
381
+ label = "reviewing";
382
+ else
383
+ reviewing = false;
384
+ labels.push(label);
385
+ }
386
+ const label = labels[labels.length - 1];
387
+ let start = labels.length - 1;
388
+ while (start > 0 && labels[start - 1] === label)
389
+ start--;
390
+ return { label, since: isoUtc(calls[start].at) ?? null, lower_bound: start === 0 };
391
+ }
392
+ export function collectCrew(project, now) {
393
+ const { config } = loadProjectYml(project.path);
394
+ const vars = config?.vars ?? {};
395
+ const rows = db
396
+ .prepare(`SELECT id, actor_id, name, command, cwd, tmux_target, tmux_socket, pane_pid, model, created_at,
397
+ state_changed_at, agent_state, session_id, transcript_path, resumed_at, todo_id
398
+ FROM agents WHERE project_id = ? AND status = 'running' AND kind = 'agent' ORDER BY id`)
399
+ .all(project.id);
400
+ const todoStmt = db.prepare("SELECT id, slug, status FROM todos WHERE id = ? AND project_id = ? AND archived_at IS NULL");
401
+ const inferredTodoStmt = db.prepare(`SELECT t.id, t.slug, t.status FROM todo_comments c
402
+ JOIN todos t ON t.id = c.todo_id
403
+ WHERE c.author = ? AND t.project_id = ? AND t.archived_at IS NULL
404
+ GROUP BY t.id
405
+ ORDER BY (t.status = 'in_progress') DESC, MAX(c.created_at) DESC, t.id DESC
406
+ LIMIT 1`);
407
+ const inferredPadStmt = db.prepare("SELECT name FROM pads WHERE project_id = ? AND updated_by = ? AND archived = 0 ORDER BY updated_at DESC, id DESC LIMIT 1");
408
+ let snapshot;
409
+ const commitsByCwd = new Map();
410
+ const staffed = new Set();
411
+ const lanes = [];
412
+ for (const row of rows) {
413
+ const todo = row.todo_id === null
414
+ ? inferredTodoStmt.get(row.actor_id, project.id) ?? null
415
+ : todoStmt.get(row.todo_id, project.id) ?? null;
416
+ const pad = row.todo_id === null && !todo
417
+ ? inferredPadStmt.get(project.id, row.actor_id)?.name
418
+ : undefined;
419
+ if (todo)
420
+ staffed.add(todo.id);
421
+ const harness = harnessFor(row.command);
422
+ const kind = harness.contextRecord;
423
+ const worker = { actor_id: row.actor_id, cwd: row.cwd, session_id: row.session_id, transcript_path: row.transcript_path };
424
+ const calls = kind ? readRecentToolCalls(kind, worker, { reviewSkills: reviewSkillsOf(vars) }).calls : [];
425
+ let state = row.agent_state;
426
+ let activity = classifyActivity(calls, vars);
427
+ if (activity.label === "")
428
+ activity = { label: state, since: null, lower_bound: false };
429
+ if (row.agent_state === "waiting") {
430
+ if (snapshot === undefined)
431
+ snapshot = liveTargets();
432
+ const owned = rowOwnership(row, snapshot) === "live";
433
+ if (owned && reportsAgentStateLog({ kind: "agent", command: row.command }) && paneClassifierFor(row.command)?.choiceCheck(row.tmux_target).awaitingChoice === true) {
434
+ state = "blocked";
435
+ activity = { label: "blocked", since: null, lower_bound: false };
436
+ }
437
+ }
438
+ if (!commitsByCwd.has(row.cwd))
439
+ commitsByCwd.set(row.cwd, readCommitsAhead(row.cwd, project.path));
440
+ lanes.push({
441
+ todo,
442
+ ...(pad ? { pad } : {}),
443
+ worker: {
444
+ id: row.id,
445
+ name: row.name,
446
+ model: row.model,
447
+ harness: harness.name,
448
+ state,
449
+ created_at: isoUtc(row.created_at) ?? row.created_at,
450
+ state_changed_at: isoUtc(row.state_changed_at),
451
+ session_id: row.session_id,
452
+ age_seconds: Math.max(0, Math.floor((now.getTime() - Date.parse(isoUtc(row.created_at) ?? "")) / 1000) || 0),
453
+ activity,
454
+ context_fill: kind ? readContextFill(kind, worker) : null,
455
+ your_turn: row.agent_state === "idle" && !awaitingFirstPrompt(row),
456
+ commits_ahead: commitsByCwd.get(row.cwd) ?? null,
457
+ },
458
+ });
459
+ }
460
+ const unstaffed = db
461
+ .prepare("SELECT id, slug, status FROM todos WHERE project_id = ? AND status = 'in_progress' AND archived_at IS NULL ORDER BY id")
462
+ .all(project.id);
463
+ for (const todo of unstaffed)
464
+ if (!staffed.has(todo.id))
465
+ lanes.push({ todo, worker: null });
466
+ const needsYou = db
467
+ .prepare("SELECT id, slug, tags FROM todos WHERE project_id = ? AND status != 'completed' AND archived_at IS NULL ORDER BY id")
468
+ .all(project.id)
469
+ .filter((t) => parseTags(t.tags).includes(NEEDS_HUMAN_TAG))
470
+ .map((t) => ({ id: t.id, slug: t.slug }));
471
+ const wakeRows = db
472
+ .prepare(`SELECT id, body, kind, watch_scope, max_wait_at, held_at, held_reason, parent_wake_id,
473
+ watch, deliver_actor, COALESCE(due_at, max_wait_at) AS due FROM wakes
474
+ WHERE project_id = ? AND ${ACTIVE_TIMER_WHERE}
475
+ ORDER BY due IS NULL, due, id`)
476
+ .all(project.id);
477
+ const standing = wakeRows.filter((w) => w.watch_scope);
478
+ const pending = wakeRows.filter((w) => !w.watch_scope);
479
+ return {
480
+ schema_version: 1,
481
+ project: { id: project.id, name: project.name },
482
+ read_at: now.toISOString(),
483
+ lanes,
484
+ needs_you: needsYou,
485
+ wakes: {
486
+ pending: pending.length,
487
+ next: pending[0]
488
+ ? {
489
+ id: pending[0].id,
490
+ label: wakeLabel(pending[0].body),
491
+ due_at: isoUtc(pending[0].due),
492
+ generated: pending[0].parent_wake_id !== null,
493
+ held: pending[0].held_at ? heldReasonLabel(pending[0].held_reason) : null,
494
+ }
495
+ : null,
496
+ watched_worker_ids: [...new Set(oneShotWatched(pending))].sort((a, b) => a - b),
497
+ watching: standing.map((w) => ({
498
+ id: w.id,
499
+ label: wakeLabel(w.body),
500
+ kind: w.kind,
501
+ scope: w.watch_scope ?? "",
502
+ max_wait_at: isoUtc(w.max_wait_at),
503
+ })),
504
+ },
505
+ context_checkpoint_percent: config?.context_checkpoint_percent ?? null,
506
+ };
507
+ }