@os-eco/overstory-cli 0.7.2 → 0.7.4

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.
Files changed (70) hide show
  1. package/README.md +21 -9
  2. package/agents/builder.md +6 -0
  3. package/agents/coordinator.md +2 -2
  4. package/agents/lead.md +4 -1
  5. package/agents/merger.md +3 -2
  6. package/agents/monitor.md +1 -1
  7. package/agents/reviewer.md +1 -0
  8. package/agents/scout.md +1 -0
  9. package/package.json +2 -2
  10. package/src/agents/hooks-deployer.test.ts +6 -5
  11. package/src/agents/identity.test.ts +3 -2
  12. package/src/agents/manifest.test.ts +4 -3
  13. package/src/agents/overlay.test.ts +3 -2
  14. package/src/commands/agents.test.ts +5 -4
  15. package/src/commands/agents.ts +18 -8
  16. package/src/commands/completions.test.ts +8 -5
  17. package/src/commands/completions.ts +37 -1
  18. package/src/commands/costs.test.ts +4 -3
  19. package/src/commands/dashboard.test.ts +265 -6
  20. package/src/commands/dashboard.ts +367 -64
  21. package/src/commands/doctor.test.ts +3 -2
  22. package/src/commands/errors.test.ts +3 -2
  23. package/src/commands/feed.test.ts +3 -2
  24. package/src/commands/feed.ts +2 -29
  25. package/src/commands/inspect.test.ts +3 -2
  26. package/src/commands/log.test.ts +248 -8
  27. package/src/commands/log.ts +193 -110
  28. package/src/commands/logs.test.ts +3 -2
  29. package/src/commands/mail.test.ts +3 -2
  30. package/src/commands/metrics.test.ts +4 -3
  31. package/src/commands/nudge.test.ts +3 -2
  32. package/src/commands/prime.test.ts +3 -2
  33. package/src/commands/prime.ts +1 -16
  34. package/src/commands/replay.test.ts +3 -2
  35. package/src/commands/run.test.ts +2 -1
  36. package/src/commands/sling.test.ts +127 -0
  37. package/src/commands/sling.ts +101 -3
  38. package/src/commands/status.test.ts +8 -8
  39. package/src/commands/trace.test.ts +3 -2
  40. package/src/commands/watch.test.ts +3 -2
  41. package/src/config.test.ts +3 -3
  42. package/src/doctor/agents.test.ts +3 -2
  43. package/src/doctor/logs.test.ts +3 -2
  44. package/src/doctor/structure.test.ts +3 -2
  45. package/src/index.ts +3 -1
  46. package/src/logging/color.ts +1 -1
  47. package/src/logging/format.test.ts +110 -0
  48. package/src/logging/format.ts +42 -1
  49. package/src/logging/logger.test.ts +3 -2
  50. package/src/mail/client.test.ts +3 -2
  51. package/src/mail/store.test.ts +3 -2
  52. package/src/merge/queue.test.ts +3 -2
  53. package/src/merge/resolver.test.ts +39 -0
  54. package/src/merge/resolver.ts +1 -1
  55. package/src/metrics/pricing.ts +80 -0
  56. package/src/metrics/transcript.test.ts +58 -1
  57. package/src/metrics/transcript.ts +9 -68
  58. package/src/mulch/client.test.ts +63 -2
  59. package/src/mulch/client.ts +62 -1
  60. package/src/runtimes/claude.test.ts +4 -3
  61. package/src/runtimes/pi-guards.test.ts +55 -2
  62. package/src/runtimes/pi-guards.ts +26 -9
  63. package/src/schema-consistency.test.ts +4 -2
  64. package/src/sessions/compat.test.ts +3 -2
  65. package/src/sessions/store.test.ts +3 -2
  66. package/src/test-helpers.ts +20 -1
  67. package/src/tracker/beads.test.ts +454 -0
  68. package/src/tracker/seeds.test.ts +461 -0
  69. package/src/watchdog/daemon.test.ts +4 -3
  70. package/src/watchdog/triage.test.ts +3 -2
@@ -7,18 +7,27 @@
7
7
  */
8
8
 
9
9
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
- import { mkdir, mkdtemp, rm } from "node:fs/promises";
10
+ import { mkdir, mkdtemp } from "node:fs/promises";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import { ValidationError } from "../errors.ts";
14
+ import { createEventStore } from "../events/store.ts";
15
+ import { color } from "../logging/color.ts";
14
16
  import { createSessionStore } from "../sessions/store.ts";
17
+ import { cleanupTempDir } from "../test-helpers.ts";
18
+ import type { DashboardStores } from "./dashboard.ts";
15
19
  import {
16
20
  closeDashboardStores,
21
+ computeAgentPanelHeight,
17
22
  dashboardCommand,
23
+ dimBox,
18
24
  filterAgentsByRun,
19
25
  horizontalLine,
20
26
  openDashboardStores,
21
27
  pad,
28
+ renderAgentPanel,
29
+ renderFeedPanel,
30
+ renderTasksPanel,
22
31
  truncate,
23
32
  } from "./dashboard.ts";
24
33
 
@@ -40,7 +49,7 @@ describe("dashboardCommand", () => {
40
49
 
41
50
  afterEach(async () => {
42
51
  process.stdout.write = originalWrite;
43
- await rm(tempDir, { recursive: true, force: true });
52
+ await cleanupTempDir(tempDir);
44
53
  });
45
54
 
46
55
  function output(): string {
@@ -203,6 +212,202 @@ describe("filterAgentsByRun", () => {
203
212
  });
204
213
  });
205
214
 
215
+ describe("dimBox", () => {
216
+ test("dimBox.vertical equals color.dim(│)", () => {
217
+ expect(dimBox.vertical).toBe(color.dim("│"));
218
+ });
219
+
220
+ test("dimBox.horizontal equals color.dim(─)", () => {
221
+ expect(dimBox.horizontal).toBe(color.dim("─"));
222
+ });
223
+
224
+ test("dimBox.tee equals color.dim(├)", () => {
225
+ expect(dimBox.tee).toBe(color.dim("├"));
226
+ });
227
+
228
+ test("dimBox.teeRight equals color.dim(┤)", () => {
229
+ expect(dimBox.teeRight).toBe(color.dim("┤"));
230
+ });
231
+
232
+ test("dimBox values equal color.dim() applied to their characters", () => {
233
+ // dimBox values are always equal to color.dim(char) regardless of whether
234
+ // Chalk emits ANSI codes (it may suppress them in non-TTY / NO_COLOR envs).
235
+ expect(dimBox.topLeft).toBe(color.dim("┌"));
236
+ expect(dimBox.topRight).toBe(color.dim("┐"));
237
+ expect(dimBox.bottomLeft).toBe(color.dim("└"));
238
+ expect(dimBox.bottomRight).toBe(color.dim("┘"));
239
+ expect(dimBox.cross).toBe(color.dim("┼"));
240
+ });
241
+ });
242
+
243
+ describe("computeAgentPanelHeight", () => {
244
+ test("0 agents: clamps to minimum 8", () => {
245
+ // max(8, min(floor(30*0.5), 0+4)) = max(8, min(15,4)) = max(8,4) = 8
246
+ expect(computeAgentPanelHeight(30, 0)).toBe(8);
247
+ });
248
+
249
+ test("4 agents: still clamps to minimum 8", () => {
250
+ // max(8, min(15, 4+4)) = max(8, 8) = 8
251
+ expect(computeAgentPanelHeight(30, 4)).toBe(8);
252
+ });
253
+
254
+ test("20 agents with height 30: clamps to floor(height*0.5)", () => {
255
+ // max(8, min(15, 24)) = max(8,15) = 15
256
+ expect(computeAgentPanelHeight(30, 20)).toBe(15);
257
+ });
258
+
259
+ test("10 agents with height 30: grows with agent count", () => {
260
+ // max(8, min(15, 14)) = max(8,14) = 14
261
+ expect(computeAgentPanelHeight(30, 10)).toBe(14);
262
+ });
263
+
264
+ test("small height: respects 50% cap", () => {
265
+ // height=20: max(8, min(10, 20+4)) = max(8,10) = 10
266
+ expect(computeAgentPanelHeight(20, 20)).toBe(10);
267
+ });
268
+ });
269
+
270
+ // Helper to build a minimal DashboardData for panel tests
271
+ function makeDashboardData(
272
+ overrides: Partial<{
273
+ tasks: Array<{ id: string; title: string; priority: number; status: string; type: string }>;
274
+ recentEvents: Array<{
275
+ id: number;
276
+ agentName: string;
277
+ eventType: string;
278
+ level: string;
279
+ createdAt: string;
280
+ runId: null;
281
+ sessionId: null;
282
+ toolName: null;
283
+ toolArgs: null;
284
+ toolDurationMs: null;
285
+ data: null;
286
+ }>;
287
+ }> = {},
288
+ ) {
289
+ return {
290
+ currentRunId: null,
291
+ status: {
292
+ currentRunId: null,
293
+ agents: [],
294
+ worktrees: [],
295
+ tmuxSessions: [],
296
+ unreadMailCount: 0,
297
+ mergeQueueCount: 0,
298
+ recentMetricsCount: 0,
299
+ },
300
+ recentMail: [],
301
+ mergeQueue: [],
302
+ metrics: { totalSessions: 0, avgDuration: 0, byCapability: {} },
303
+ tasks: overrides.tasks ?? [],
304
+ recentEvents: (overrides.recentEvents as never[]) ?? [],
305
+ };
306
+ }
307
+
308
+ describe("renderTasksPanel", () => {
309
+ test("renders task id in output", () => {
310
+ const data = makeDashboardData({
311
+ tasks: [{ id: "t1", title: "Test task", priority: 2, status: "open", type: "task" }],
312
+ });
313
+ const out = renderTasksPanel(data, 1, 80, 10, 1);
314
+ expect(out).toContain("t1");
315
+ });
316
+
317
+ test("renders task title in output", () => {
318
+ const data = makeDashboardData({
319
+ tasks: [{ id: "t1", title: "Test task", priority: 2, status: "open", type: "task" }],
320
+ });
321
+ const out = renderTasksPanel(data, 1, 80, 10, 1);
322
+ expect(out).toContain("Test task");
323
+ });
324
+
325
+ test("renders priority label in output", () => {
326
+ const data = makeDashboardData({
327
+ tasks: [{ id: "t1", title: "Test task", priority: 2, status: "open", type: "task" }],
328
+ });
329
+ const out = renderTasksPanel(data, 1, 80, 10, 1);
330
+ expect(out).toContain("P2");
331
+ });
332
+
333
+ test("shows 'No tracker data' when tasks list is empty", () => {
334
+ const data = makeDashboardData({ tasks: [] });
335
+ const out = renderTasksPanel(data, 1, 80, 10, 1);
336
+ expect(out).toContain("No tracker data");
337
+ });
338
+
339
+ test("renders Tasks header", () => {
340
+ const data = makeDashboardData({ tasks: [] });
341
+ const out = renderTasksPanel(data, 1, 80, 6, 1);
342
+ expect(out).toContain("Tasks");
343
+ });
344
+
345
+ test("renders multiple tasks", () => {
346
+ const data = makeDashboardData({
347
+ tasks: [
348
+ { id: "abc-001", title: "First task", priority: 1, status: "open", type: "task" },
349
+ { id: "abc-002", title: "Second task", priority: 3, status: "in_progress", type: "bug" },
350
+ ],
351
+ });
352
+ const out = renderTasksPanel(data, 1, 80, 10, 1);
353
+ expect(out).toContain("abc-001");
354
+ expect(out).toContain("abc-002");
355
+ });
356
+ });
357
+
358
+ describe("renderFeedPanel", () => {
359
+ test("shows 'No recent events' when recentEvents is empty", () => {
360
+ const data = makeDashboardData({ recentEvents: [] });
361
+ const out = renderFeedPanel(data, 1, 80, 8, 1);
362
+ expect(out).toContain("No recent events");
363
+ });
364
+
365
+ test("renders Feed header", () => {
366
+ const data = makeDashboardData({ recentEvents: [] });
367
+ const out = renderFeedPanel(data, 1, 80, 8, 1);
368
+ expect(out).toContain("Feed");
369
+ });
370
+
371
+ test("renders event agent name when events are present", () => {
372
+ const event = {
373
+ id: 1,
374
+ agentName: "test-agent",
375
+ eventType: "tool_end" as const,
376
+ level: "info" as const,
377
+ createdAt: new Date().toISOString(),
378
+ runId: null,
379
+ sessionId: null,
380
+ toolName: null,
381
+ toolArgs: null,
382
+ toolDurationMs: null,
383
+ data: null,
384
+ };
385
+ const data = makeDashboardData({ recentEvents: [event] });
386
+ // formatEventLine is a stub — returns "" — so output won't have agent name from it.
387
+ // But the panel itself should not throw and should render the border structure.
388
+ const out = renderFeedPanel(data, 1, 80, 8, 1);
389
+ // Panel renders without error and contains Feed header
390
+ expect(out).toContain("Feed");
391
+ // At least 1 row rendered (not the "No recent events" path)
392
+ expect(out).not.toContain("No recent events");
393
+ });
394
+ });
395
+
396
+ describe("renderAgentPanel", () => {
397
+ test("renders Agents header", () => {
398
+ const data = makeDashboardData({});
399
+ const out = renderAgentPanel(data, 100, 12, 3);
400
+ expect(out).toContain("Agents");
401
+ });
402
+
403
+ test("renders with dimmed border characters", () => {
404
+ const data = makeDashboardData({});
405
+ const out = renderAgentPanel(data, 100, 12, 3);
406
+ // dimBox.vertical is a dimmed ANSI string — present in output
407
+ expect(out).toContain(dimBox.vertical);
408
+ });
409
+ });
410
+
206
411
  describe("openDashboardStores", () => {
207
412
  let tempDir: string;
208
413
 
@@ -211,14 +416,12 @@ describe("openDashboardStores", () => {
211
416
  });
212
417
 
213
418
  afterEach(async () => {
214
- await rm(tempDir, { recursive: true, force: true });
419
+ await cleanupTempDir(tempDir);
215
420
  });
216
421
 
217
422
  test("sessionStore is non-null when .overstory/ has sessions.db", async () => {
218
- // Create the .overstory directory and seed a sessions.db via createSessionStore
219
423
  const overstoryDir = join(tempDir, ".overstory");
220
424
  await mkdir(overstoryDir, { recursive: true });
221
- // createSessionStore creates and initialises sessions.db
222
425
  const seeder = createSessionStore(join(overstoryDir, "sessions.db"));
223
426
  seeder.close();
224
427
 
@@ -271,6 +474,38 @@ describe("openDashboardStores", () => {
271
474
  closeDashboardStores(stores);
272
475
  }
273
476
  });
477
+
478
+ test("eventStore is null when events.db does not exist", async () => {
479
+ const overstoryDir = join(tempDir, ".overstory");
480
+ await mkdir(overstoryDir, { recursive: true });
481
+ const seeder = createSessionStore(join(overstoryDir, "sessions.db"));
482
+ seeder.close();
483
+
484
+ const stores = openDashboardStores(tempDir);
485
+ try {
486
+ expect(stores.eventStore).toBeNull();
487
+ } finally {
488
+ closeDashboardStores(stores);
489
+ }
490
+ });
491
+
492
+ test("eventStore is non-null when events.db exists", async () => {
493
+ const overstoryDir = join(tempDir, ".overstory");
494
+ await mkdir(overstoryDir, { recursive: true });
495
+ const seeder = createSessionStore(join(overstoryDir, "sessions.db"));
496
+ seeder.close();
497
+
498
+ // Create events.db via createEventStore
499
+ const eventsDb = createEventStore(join(overstoryDir, "events.db"));
500
+ eventsDb.close();
501
+
502
+ const stores = openDashboardStores(tempDir);
503
+ try {
504
+ expect(stores.eventStore).not.toBeNull();
505
+ } finally {
506
+ closeDashboardStores(stores);
507
+ }
508
+ });
274
509
  });
275
510
 
276
511
  describe("closeDashboardStores", () => {
@@ -281,7 +516,7 @@ describe("closeDashboardStores", () => {
281
516
  });
282
517
 
283
518
  afterEach(async () => {
284
- await rm(tempDir, { recursive: true, force: true });
519
+ await cleanupTempDir(tempDir);
285
520
  });
286
521
 
287
522
  test("closing stores does not throw", async () => {
@@ -305,4 +540,28 @@ describe("closeDashboardStores", () => {
305
540
  // Second close should not throw due to best-effort try/catch
306
541
  expect(() => closeDashboardStores(stores)).not.toThrow();
307
542
  });
543
+
544
+ test("closing stores with eventStore does not throw", async () => {
545
+ const overstoryDir = join(tempDir, ".overstory");
546
+ await mkdir(overstoryDir, { recursive: true });
547
+ const seeder = createSessionStore(join(overstoryDir, "sessions.db"));
548
+ seeder.close();
549
+ const eventsDb = createEventStore(join(overstoryDir, "events.db"));
550
+ eventsDb.close();
551
+
552
+ const stores = openDashboardStores(tempDir);
553
+ expect(() => closeDashboardStores(stores)).not.toThrow();
554
+ });
555
+ });
556
+
557
+ // Type check: DashboardStores includes eventStore
558
+ test("DashboardStores type includes eventStore field", () => {
559
+ const stores: DashboardStores = {
560
+ sessionStore: null as never,
561
+ mailStore: null,
562
+ mergeQueue: null,
563
+ metricsStore: null,
564
+ eventStore: null,
565
+ };
566
+ expect(stores.eventStore).toBeNull();
308
567
  });