@os-eco/overstory-cli 0.7.2 → 0.7.3

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 (56) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/agents/hooks-deployer.test.ts +6 -5
  4. package/src/agents/identity.test.ts +3 -2
  5. package/src/agents/manifest.test.ts +4 -3
  6. package/src/agents/overlay.test.ts +3 -2
  7. package/src/commands/agents.test.ts +5 -4
  8. package/src/commands/completions.test.ts +8 -5
  9. package/src/commands/completions.ts +37 -1
  10. package/src/commands/costs.test.ts +4 -3
  11. package/src/commands/dashboard.test.ts +265 -6
  12. package/src/commands/dashboard.ts +367 -64
  13. package/src/commands/doctor.test.ts +3 -2
  14. package/src/commands/errors.test.ts +3 -2
  15. package/src/commands/feed.test.ts +3 -2
  16. package/src/commands/feed.ts +2 -29
  17. package/src/commands/inspect.test.ts +3 -2
  18. package/src/commands/log.test.ts +248 -8
  19. package/src/commands/log.ts +193 -110
  20. package/src/commands/logs.test.ts +3 -2
  21. package/src/commands/mail.test.ts +3 -2
  22. package/src/commands/metrics.test.ts +4 -3
  23. package/src/commands/nudge.test.ts +3 -2
  24. package/src/commands/prime.test.ts +2 -2
  25. package/src/commands/replay.test.ts +3 -2
  26. package/src/commands/run.test.ts +2 -1
  27. package/src/commands/sling.test.ts +127 -0
  28. package/src/commands/sling.ts +101 -3
  29. package/src/commands/status.test.ts +8 -8
  30. package/src/commands/trace.test.ts +3 -2
  31. package/src/commands/watch.test.ts +3 -2
  32. package/src/config.test.ts +3 -3
  33. package/src/doctor/agents.test.ts +3 -2
  34. package/src/doctor/logs.test.ts +3 -2
  35. package/src/doctor/structure.test.ts +3 -2
  36. package/src/index.ts +3 -1
  37. package/src/logging/color.ts +1 -1
  38. package/src/logging/format.test.ts +110 -0
  39. package/src/logging/format.ts +42 -1
  40. package/src/logging/logger.test.ts +3 -2
  41. package/src/mail/client.test.ts +3 -2
  42. package/src/mail/store.test.ts +3 -2
  43. package/src/merge/queue.test.ts +3 -2
  44. package/src/merge/resolver.test.ts +39 -0
  45. package/src/merge/resolver.ts +1 -1
  46. package/src/mulch/client.test.ts +63 -2
  47. package/src/mulch/client.ts +62 -1
  48. package/src/runtimes/claude.test.ts +4 -3
  49. package/src/runtimes/pi-guards.test.ts +26 -2
  50. package/src/runtimes/pi-guards.ts +3 -3
  51. package/src/schema-consistency.test.ts +4 -2
  52. package/src/sessions/compat.test.ts +3 -2
  53. package/src/sessions/store.test.ts +3 -2
  54. package/src/test-helpers.ts +20 -1
  55. package/src/watchdog/daemon.test.ts +4 -3
  56. package/src/watchdog/triage.test.ts +3 -2
package/README.md CHANGED
@@ -75,7 +75,7 @@ Every command supports `--json` where noted. Global flags: `-q`/`--quiet`, `--ti
75
75
  | Command | Description |
76
76
  |---------|-------------|
77
77
  | `ov init` | Initialize `.overstory/` in current project (`--yes`, `--name`) |
78
- | `ov sling <task-id>` | Spawn a worker agent (`--capability`, `--name`, `--spec`, `--files`, `--parent`, `--depth`, `--skip-scout`, `--skip-review`, `--max-agents`, `--dispatch-max-agents`, `--skip-task-check`, `--runtime`, `--json`) |
78
+ | `ov sling <task-id>` | Spawn a worker agent (`--capability`, `--name`, `--spec`, `--files`, `--parent`, `--depth`, `--skip-scout`, `--skip-review`, `--max-agents`, `--dispatch-max-agents`, `--skip-task-check`, `--no-scout-check`, `--runtime`, `--json`) |
79
79
  | `ov stop <agent-name>` | Terminate a running agent (`--clean-worktree`, `--json`) |
80
80
  | `ov prime` | Load context for orchestrator/agent (`--agent`, `--compact`) |
81
81
  | `ov spec write <task-id>` | Write a task specification (`--body`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@os-eco/overstory-cli",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "Multi-agent orchestration for Claude Code — spawn worker agents in git worktrees via tmux, coordinate through SQLite mail, merge with tiered conflict resolution",
5
5
  "author": "Jaymin West",
6
6
  "license": "MIT",
@@ -1,8 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtemp, rm } from "node:fs/promises";
2
+ import { mkdtemp } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { AgentError } from "../errors.ts";
6
+ import { cleanupTempDir } from "../test-helpers.ts";
6
7
  import {
7
8
  buildBashFileGuardScript,
8
9
  buildBashPathBoundaryScript,
@@ -26,7 +27,7 @@ describe("deployHooks", () => {
26
27
  });
27
28
 
28
29
  afterEach(async () => {
29
- await rm(tempDir, { recursive: true, force: true });
30
+ await cleanupTempDir(tempDir);
30
31
  });
31
32
 
32
33
  test("creates .claude/settings.local.json in worktree directory", async () => {
@@ -1482,7 +1483,7 @@ describe("structural enforcement integration", () => {
1482
1483
  });
1483
1484
 
1484
1485
  afterEach(async () => {
1485
- await rm(tempDir, { recursive: true, force: true });
1486
+ await cleanupTempDir(tempDir);
1486
1487
  });
1487
1488
 
1488
1489
  test("non-implementation agents have more guards than implementation agents", async () => {
@@ -2055,7 +2056,7 @@ describe("bash path boundary integration", () => {
2055
2056
  });
2056
2057
 
2057
2058
  afterEach(async () => {
2058
- await rm(tempDir, { recursive: true, force: true });
2059
+ await cleanupTempDir(tempDir);
2059
2060
  });
2060
2061
 
2061
2062
  test("builder gets Bash path boundary guard in deployed hooks", async () => {
@@ -2249,7 +2250,7 @@ describe("PATH prefix in deployed hooks", () => {
2249
2250
  });
2250
2251
 
2251
2252
  afterEach(async () => {
2252
- await rm(tempDir, { recursive: true, force: true });
2253
+ await cleanupTempDir(tempDir);
2253
2254
  });
2254
2255
 
2255
2256
  test("SessionStart hook commands include PATH prefix", async () => {
@@ -1,8 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdir, mkdtemp, rm } from "node:fs/promises";
2
+ import { mkdir, mkdtemp } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { AgentError } from "../errors.ts";
6
+ import { cleanupTempDir } from "../test-helpers.ts";
6
7
  import type { AgentIdentity } from "../types.ts";
7
8
  import { createIdentity, loadIdentity, updateIdentity } from "./identity.ts";
8
9
 
@@ -14,7 +15,7 @@ describe("identity", () => {
14
15
  });
15
16
 
16
17
  afterEach(async () => {
17
- await rm(tempDir, { recursive: true, force: true });
18
+ await cleanupTempDir(tempDir);
18
19
  });
19
20
 
20
21
  describe("createIdentity", () => {
@@ -1,8 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdir, mkdtemp, rm } from "node:fs/promises";
2
+ import { mkdir, mkdtemp } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { AgentError } from "../errors.ts";
6
+ import { cleanupTempDir } from "../test-helpers.ts";
6
7
  import type { AgentManifest, OverstoryConfig } from "../types.ts";
7
8
  import { createManifestLoader, resolveModel, resolveProviderEnv } from "./manifest.ts";
8
9
 
@@ -41,7 +42,7 @@ describe("createManifestLoader", () => {
41
42
  });
42
43
 
43
44
  afterEach(async () => {
44
- await rm(tempDir, { recursive: true, force: true });
45
+ await cleanupTempDir(tempDir);
45
46
  });
46
47
 
47
48
  /** Write the manifest JSON and create matching .md files. */
@@ -806,7 +807,7 @@ describe("manifest validation accepts arbitrary model strings", () => {
806
807
  });
807
808
 
808
809
  afterEach(async () => {
809
- await rm(tempDir, { recursive: true, force: true });
810
+ await cleanupTempDir(tempDir);
810
811
  });
811
812
 
812
813
  test("accepts provider-prefixed model string", async () => {
@@ -1,8 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdir, mkdtemp, rm } from "node:fs/promises";
2
+ import { mkdir, mkdtemp } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { AgentError } from "../errors.ts";
6
+ import { cleanupTempDir } from "../test-helpers.ts";
6
7
  import type { OverlayConfig, QualityGate } from "../types.ts";
7
8
  import {
8
9
  formatQualityGatesBash,
@@ -584,7 +585,7 @@ describe("writeOverlay", () => {
584
585
  });
585
586
 
586
587
  afterEach(async () => {
587
- await rm(tempDir, { recursive: true, force: true });
588
+ await cleanupTempDir(tempDir);
588
589
  });
589
590
 
590
591
  test("creates .claude/CLAUDE.md in worktree directory", async () => {
@@ -3,10 +3,11 @@
3
3
  */
4
4
 
5
5
  import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
6
- import { mkdtemp, rm } from "node:fs/promises";
6
+ import { mkdtemp } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { createSessionStore } from "../sessions/store.ts";
10
+ import { cleanupTempDir } from "../test-helpers.ts";
10
11
  import type { AgentSession } from "../types.ts";
11
12
  import { agentsCommand, discoverAgents, extractFileScope } from "./agents.ts";
12
13
 
@@ -65,7 +66,7 @@ Some expertise here.
65
66
  });
66
67
 
67
68
  afterEach(async () => {
68
- await rm(tempDir, { recursive: true, force: true });
69
+ await cleanupTempDir(tempDir);
69
70
  });
70
71
  });
71
72
 
@@ -235,7 +236,7 @@ describe("discoverAgents", () => {
235
236
  });
236
237
 
237
238
  afterEach(async () => {
238
- await rm(tempDir, { recursive: true, force: true });
239
+ await cleanupTempDir(tempDir);
239
240
  });
240
241
  });
241
242
 
@@ -322,6 +323,6 @@ logging:
322
323
  afterEach(async () => {
323
324
  process.stdout.write = originalStdoutWrite;
324
325
  process.chdir(originalCwd);
325
- await rm(tempDir, { recursive: true, force: true });
326
+ await cleanupTempDir(tempDir);
326
327
  });
327
328
  });
@@ -12,8 +12,8 @@ import {
12
12
  } from "./completions.ts";
13
13
 
14
14
  describe("COMMANDS array", () => {
15
- it("should have exactly 30 commands", () => {
16
- expect(COMMANDS).toHaveLength(30);
15
+ it("should have exactly 33 commands", () => {
16
+ expect(COMMANDS).toHaveLength(33);
17
17
  });
18
18
 
19
19
  it("should include all expected command names", () => {
@@ -48,6 +48,9 @@ describe("COMMANDS array", () => {
48
48
  expect(names).toContain("feed");
49
49
  expect(names).toContain("logs");
50
50
  expect(names).toContain("stop");
51
+ expect(names).toContain("ecosystem");
52
+ expect(names).toContain("upgrade");
53
+ expect(names).toContain("completions");
51
54
  });
52
55
  });
53
56
 
@@ -59,7 +62,7 @@ describe("generateBash", () => {
59
62
  expect(script).toContain("_init_completion");
60
63
  });
61
64
 
62
- it("should include all 27 command names", () => {
65
+ it("should include all 33 command names", () => {
63
66
  const script = generateBash();
64
67
  for (const cmd of COMMANDS) {
65
68
  expect(script).toContain(cmd.name);
@@ -93,7 +96,7 @@ describe("generateZsh", () => {
93
96
  expect(script).toContain("_arguments");
94
97
  });
95
98
 
96
- it("should include all 27 command names", () => {
99
+ it("should include all 33 command names", () => {
97
100
  const script = generateZsh();
98
101
  for (const cmd of COMMANDS) {
99
102
  expect(script).toContain(cmd.name);
@@ -123,7 +126,7 @@ describe("generateFish", () => {
123
126
  expect(script).toContain("__fish_use_subcommand");
124
127
  });
125
128
 
126
- it("should include all 27 command names", () => {
129
+ it("should include all 33 command names", () => {
127
130
  const script = generateFish();
128
131
  for (const cmd of COMMANDS) {
129
132
  expect(script).toContain(cmd.name);
@@ -58,6 +58,9 @@ export const COMMANDS: readonly CommandDef[] = [
58
58
  desc: "Initialize .overstory/ in current project",
59
59
  flags: [
60
60
  { name: "--force", desc: "Overwrite existing configuration" },
61
+ { name: "--yes", desc: "Accept all defaults without prompting" },
62
+ { name: "-y", desc: "Alias for --yes" },
63
+ { name: "--name", desc: "Project name", takesValue: true },
61
64
  { name: "--help", desc: "Show help" },
62
65
  ],
63
66
  },
@@ -76,7 +79,13 @@ export const COMMANDS: readonly CommandDef[] = [
76
79
  { name: "--files", desc: "Exclusive file scope (comma-separated)", takesValue: true },
77
80
  { name: "--parent", desc: "Parent agent name", takesValue: true },
78
81
  { name: "--depth", desc: "Current hierarchy depth", takesValue: true },
82
+ { name: "--skip-scout", desc: "Skip scout phase for lead agents" },
83
+ { name: "--skip-review", desc: "Skip review phase for lead agents" },
84
+ { name: "--skip-task-check", desc: "Skip task existence validation" },
79
85
  { name: "--force-hierarchy", desc: "Bypass hierarchy validation" },
86
+ { name: "--max-agents", desc: "Max children per lead", takesValue: true },
87
+ { name: "--dispatch-max-agents", desc: "Per-lead max agents ceiling", takesValue: true },
88
+ { name: "--runtime", desc: "Runtime adapter", takesValue: true },
80
89
  { name: "--json", desc: "JSON output" },
81
90
  { name: "--help", desc: "Show help" },
82
91
  ],
@@ -107,6 +116,7 @@ export const COMMANDS: readonly CommandDef[] = [
107
116
  { name: "--json", desc: "JSON output" },
108
117
  { name: "--verbose", desc: "Extra per-agent detail" },
109
118
  { name: "--agent", desc: "Filter by agent", takesValue: true },
119
+ { name: "--all", desc: "Show sessions from all runs" },
110
120
  { name: "--watch", desc: "Watch mode" },
111
121
  { name: "--interval", desc: "Poll interval in ms", takesValue: true },
112
122
  { name: "--help", desc: "Show help" },
@@ -138,6 +148,7 @@ export const COMMANDS: readonly CommandDef[] = [
138
148
  flags: [
139
149
  { name: "--branch", desc: "Specific branch to merge", takesValue: true },
140
150
  { name: "--all", desc: "All completed branches" },
151
+ { name: "--into", desc: "Target branch to merge into", takesValue: true },
141
152
  { name: "--dry-run", desc: "Check for conflicts only" },
142
153
  { name: "--json", desc: "JSON output" },
143
154
  { name: "--help", desc: "Show help" },
@@ -190,8 +201,10 @@ export const COMMANDS: readonly CommandDef[] = [
190
201
  "merge",
191
202
  "logs",
192
203
  "version",
204
+ "ecosystem",
193
205
  ],
194
206
  },
207
+ { name: "--fix", desc: "Attempt to auto-fix issues" },
195
208
  { name: "--help", desc: "Show help" },
196
209
  ],
197
210
  },
@@ -606,6 +619,29 @@ export const COMMANDS: readonly CommandDef[] = [
606
619
  },
607
620
  ],
608
621
  },
622
+ {
623
+ name: "ecosystem",
624
+ desc: "Show a summary dashboard of all installed os-eco tools",
625
+ flags: [
626
+ { name: "--json", desc: "JSON output" },
627
+ { name: "--help", desc: "Show help" },
628
+ ],
629
+ },
630
+ {
631
+ name: "upgrade",
632
+ desc: "Upgrade overstory to the latest version",
633
+ flags: [
634
+ { name: "--check", desc: "Compare current vs latest without installing" },
635
+ { name: "--all", desc: "Upgrade all os-eco tools" },
636
+ { name: "--json", desc: "JSON output" },
637
+ { name: "--help", desc: "Show help" },
638
+ ],
639
+ },
640
+ {
641
+ name: "completions",
642
+ desc: "Generate shell completions",
643
+ flags: [{ name: "--help", desc: "Show help" }],
644
+ },
609
645
  ] as const;
610
646
 
611
647
  export function generateBash(): string {
@@ -618,7 +654,7 @@ export function generateBash(): string {
618
654
  " local cur prev words cword",
619
655
  " _init_completion || return",
620
656
  "",
621
- " local commands='init sling prime stop status dashboard inspect merge nudge clean doctor log logs watch trace errors feed replay costs metrics spec coordinator supervisor hooks monitor mail group worktree run'",
657
+ " local commands='agents init sling prime stop status dashboard inspect merge nudge clean doctor log logs watch trace errors feed replay costs metrics spec coordinator supervisor hooks monitor mail group worktree run ecosystem upgrade completions'",
622
658
  "",
623
659
  " # Top-level completion",
624
660
  " if [[ $cword -eq 1 ]]; then",
@@ -9,12 +9,13 @@
9
9
  */
10
10
 
11
11
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
- import { mkdir, mkdtemp, rm } from "node:fs/promises";
12
+ import { mkdir, mkdtemp } from "node:fs/promises";
13
13
  import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { ValidationError } from "../errors.ts";
16
16
  import { createMetricsStore } from "../metrics/store.ts";
17
17
  import { createSessionStore } from "../sessions/store.ts";
18
+ import { cleanupTempDir } from "../test-helpers.ts";
18
19
  import type { SessionMetrics } from "../types.ts";
19
20
  import { costsCommand } from "./costs.ts";
20
21
 
@@ -72,7 +73,7 @@ describe("costsCommand", () => {
72
73
  afterEach(async () => {
73
74
  process.stdout.write = originalWrite;
74
75
  process.chdir(originalCwd);
75
- await rm(tempDir, { recursive: true, force: true });
76
+ await cleanupTempDir(tempDir);
76
77
  });
77
78
 
78
79
  function output(): string {
@@ -1052,7 +1053,7 @@ describe("costsCommand", () => {
1052
1053
 
1053
1054
  afterEach(async () => {
1054
1055
  process.env.HOME = originalHome;
1055
- await rm(tempHome, { recursive: true, force: true });
1056
+ await cleanupTempDir(tempHome);
1056
1057
  });
1057
1058
 
1058
1059
  test("--self shows orchestrator cost when transcript exists", async () => {
@@ -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
  });