@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
@@ -123,6 +123,7 @@ export interface SlingOptions {
123
123
  skipReview?: boolean;
124
124
  dispatchMaxAgents?: string;
125
125
  runtime?: string;
126
+ noScoutCheck?: boolean;
126
127
  }
127
128
 
128
129
  export interface AutoDispatchOptions {
@@ -215,6 +216,38 @@ export function parentHasScouts(
215
216
  return sessions.some((s) => s.parentAgent === parentAgent && s.capability === "scout");
216
217
  }
217
218
 
219
+ /**
220
+ * Determine whether to emit the scout-before-build warning.
221
+ *
222
+ * Returns true when all of the following hold:
223
+ * - The incoming capability is "builder" (only builders trigger the check)
224
+ * - A parent agent is set (orphaned builders don't trigger it)
225
+ * - The parent has not yet spawned any scouts
226
+ * - noScoutCheck is false (caller has not suppressed the warning)
227
+ * - skipScout is false (the lead is not intentionally running without scouts)
228
+ *
229
+ * Extracted from slingCommand for testability (overstory-6eyw).
230
+ *
231
+ * @param capability - The requested agent capability
232
+ * @param parentAgent - The --parent flag value (null = coordinator/human)
233
+ * @param sessions - All sessions (not just active) for parentHasScouts query
234
+ * @param noScoutCheck - True when --no-scout-check flag is set
235
+ * @param skipScout - True when --skip-scout flag is set (lead opted out of scouting)
236
+ */
237
+ export function shouldShowScoutWarning(
238
+ capability: string,
239
+ parentAgent: string | null,
240
+ sessions: ReadonlyArray<{ parentAgent: string | null; capability: string }>,
241
+ noScoutCheck: boolean,
242
+ skipScout: boolean,
243
+ ): boolean {
244
+ if (capability !== "builder") return false;
245
+ if (parentAgent === null) return false;
246
+ if (noScoutCheck) return false;
247
+ if (skipScout) return false;
248
+ return !parentHasScouts(sessions, parentAgent);
249
+ }
250
+
218
251
  /**
219
252
  * Check if any active agent is already working on the given task ID.
220
253
  * Returns the agent name if locked, or null if the task is free.
@@ -319,6 +352,43 @@ export function validateHierarchy(
319
352
  }
320
353
  }
321
354
 
355
+ /**
356
+ * Extract mulch record IDs and their domains from mulch prime output text.
357
+ * Parses the markdown structure produced by ml prime: domain headings
358
+ * (## <name>) followed by record lines containing (mx-XXXXXX) identifiers.
359
+ * @param primeText - The output text from ml prime
360
+ * @returns Array of {id, domain} pairs. Deduplicated.
361
+ */
362
+ export function extractMulchRecordIds(primeText: string): Array<{ id: string; domain: string }> {
363
+ const results: Array<{ id: string; domain: string }> = [];
364
+ const seen = new Set<string>();
365
+ let currentDomain = "";
366
+
367
+ for (const line of primeText.split("\n")) {
368
+ const domainMatch = line.match(/^## ([\w-]+)/);
369
+ if (domainMatch) {
370
+ currentDomain = domainMatch[1] ?? "";
371
+ continue;
372
+ }
373
+ if (currentDomain) {
374
+ const idRegex = /\(mx-([a-f0-9]+)\)/g;
375
+ let match = idRegex.exec(line);
376
+ while (match !== null) {
377
+ const shortId = match[1] ?? "";
378
+ if (shortId) {
379
+ const key = `${currentDomain}:mx-${shortId}`;
380
+ if (!seen.has(key)) {
381
+ seen.add(key);
382
+ results.push({ id: `mx-${shortId}`, domain: currentDomain });
383
+ }
384
+ }
385
+ match = idRegex.exec(line);
386
+ }
387
+ }
388
+ }
389
+ return results;
390
+ }
391
+
322
392
  /**
323
393
  * Entry point for `ov sling <task-id> [flags]`.
324
394
  *
@@ -544,7 +614,16 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
544
614
  // 5c. Structural enforcement: warn when a lead spawns a builder without prior scouts.
545
615
  // This is a non-blocking warning — it does not prevent the spawn, but surfaces
546
616
  // the scout-skip pattern so agents and operators can see it happening.
547
- if (capability === "builder" && parentAgent && !parentHasScouts(store.getAll(), parentAgent)) {
617
+ // Use --no-scout-check to suppress this warning when intentionally skipping scouts.
618
+ if (
619
+ shouldShowScoutWarning(
620
+ capability,
621
+ parentAgent,
622
+ store.getAll(),
623
+ opts.noScoutCheck ?? false,
624
+ skipScout,
625
+ )
626
+ ) {
548
627
  process.stderr.write(
549
628
  `Warning: "${parentAgent}" is spawning builder "${name}" without having spawned any scouts.\n`,
550
629
  );
@@ -596,7 +675,10 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
596
675
  if (config.mulch.enabled && fileScope.length > 0) {
597
676
  try {
598
677
  const mulch = createMulchClient(config.project.root);
599
- mulchExpertise = await mulch.prime(undefined, undefined, { files: fileScope });
678
+ mulchExpertise = await mulch.prime(undefined, undefined, {
679
+ files: fileScope,
680
+ sortByScore: true,
681
+ });
600
682
  } catch {
601
683
  // Non-fatal: mulch expertise is supplementary context
602
684
  mulchExpertise = undefined;
@@ -709,7 +791,23 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
709
791
  });
710
792
  }
711
793
 
712
- // 11b. Preflight: verify tmux is available before attempting session creation
794
+ // 11b. Save applied mulch record IDs for session-end outcome tracking.
795
+ // Written to .overstory/agents/{name}/applied-records.json so log.ts
796
+ // can append outcomes when the session completes.
797
+ if (mulchExpertise) {
798
+ const appliedRecords = extractMulchRecordIds(mulchExpertise);
799
+ if (appliedRecords.length > 0) {
800
+ const appliedRecordsPath = join(identityBaseDir, name, "applied-records.json");
801
+ const appliedData = { taskId, agentName: name, capability, records: appliedRecords };
802
+ try {
803
+ await Bun.write(appliedRecordsPath, `${JSON.stringify(appliedData, null, "\t")}\n`);
804
+ } catch {
805
+ // Non-fatal: outcome tracking is supplementary context
806
+ }
807
+ }
808
+ }
809
+
810
+ // 11c. Preflight: verify tmux is available before attempting session creation
713
811
  await ensureTmuxAvailable();
714
812
 
715
813
  // 12. Create tmux session running claude in interactive mode
@@ -1,10 +1,10 @@
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 { stripAnsi } from "../logging/color.ts";
6
6
  import { createSessionStore } from "../sessions/store.ts";
7
- import { createTempGitRepo } from "../test-helpers.ts";
7
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
8
8
  import type { AgentSession } from "../types.ts";
9
9
  import {
10
10
  gatherStatus,
@@ -344,7 +344,7 @@ describe("run scoping", () => {
344
344
  // out-of-scope builder must NOT appear
345
345
  expect(names).not.toContain("builder-2");
346
346
  } finally {
347
- await rm(tempDir, { recursive: true, force: true });
347
+ await cleanupTempDir(tempDir);
348
348
  }
349
349
  });
350
350
  });
@@ -391,7 +391,7 @@ describe("--watch deprecation", () => {
391
391
  } finally {
392
392
  process.stderr.write = originalStderr;
393
393
  process.chdir(originalCwd);
394
- await rm(tmpDir, { recursive: true, force: true });
394
+ await cleanupTempDir(tmpDir);
395
395
  }
396
396
 
397
397
  const err = stderrChunks.join("");
@@ -432,7 +432,7 @@ describe("gatherStatus reconciliation", () => {
432
432
  expect(agent).toBeDefined();
433
433
  expect(agent?.state).toBe("zombie");
434
434
  } finally {
435
- await rm(tempDir, { recursive: true, force: true });
435
+ await cleanupTempDir(tempDir);
436
436
  }
437
437
  });
438
438
 
@@ -461,7 +461,7 @@ describe("gatherStatus reconciliation", () => {
461
461
  expect(agent).toBeDefined();
462
462
  expect(agent?.state).toBe("completed");
463
463
  } finally {
464
- await rm(tempDir, { recursive: true, force: true });
464
+ await cleanupTempDir(tempDir);
465
465
  }
466
466
  });
467
467
 
@@ -491,7 +491,7 @@ describe("gatherStatus reconciliation", () => {
491
491
  expect(agent).toBeDefined();
492
492
  expect(agent?.state).toBe("zombie");
493
493
  } finally {
494
- await rm(tempDir, { recursive: true, force: true });
494
+ await cleanupTempDir(tempDir);
495
495
  }
496
496
  });
497
497
  });
@@ -522,7 +522,7 @@ describe("subprocess caching (invalidateStatusCache)", () => {
522
522
  expect(Array.isArray(result1.worktrees)).toBe(true);
523
523
  expect(Array.isArray(result2.worktrees)).toBe(true);
524
524
  } finally {
525
- await rm(tempDir, { recursive: true, force: true });
525
+ await cleanupTempDir(tempDir);
526
526
  }
527
527
  });
528
528
  });
@@ -9,13 +9,14 @@
9
9
  */
10
10
 
11
11
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
- import { mkdtemp, rm } from "node:fs/promises";
12
+ import { 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 { createEventStore } from "../events/store.ts";
17
17
  import { stripAnsi } from "../logging/color.ts";
18
18
  import { createSessionStore } from "../sessions/store.ts";
19
+ import { cleanupTempDir } from "../test-helpers.ts";
19
20
  import type { InsertEvent } from "../types.ts";
20
21
  import { traceCommand } from "./trace.ts";
21
22
 
@@ -66,7 +67,7 @@ describe("traceCommand", () => {
66
67
  afterEach(async () => {
67
68
  process.stdout.write = originalWrite;
68
69
  process.chdir(originalCwd);
69
- await rm(tempDir, { recursive: true, force: true });
70
+ await cleanupTempDir(tempDir);
70
71
  });
71
72
 
72
73
  function output(): string {
@@ -1,7 +1,8 @@
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
+ import { cleanupTempDir } from "../test-helpers.ts";
5
6
  import { watchCommand } from "./watch.ts";
6
7
 
7
8
  /**
@@ -66,7 +67,7 @@ describe("watchCommand", () => {
66
67
  process.stderr.write = originalStderrWrite;
67
68
  process.exitCode = originalExitCode;
68
69
  process.chdir(originalCwd);
69
- await rm(tempDir, { recursive: true, force: true });
70
+ await cleanupTempDir(tempDir);
70
71
  });
71
72
 
72
73
  function output(): string {
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, realpath } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { DEFAULT_CONFIG, DEFAULT_QUALITY_GATES, loadConfig, resolveProjectRoot } from "./config.ts";
@@ -14,7 +14,7 @@ describe("loadConfig", () => {
14
14
  });
15
15
 
16
16
  afterEach(async () => {
17
- await rm(tempDir, { recursive: true, force: true });
17
+ await cleanupTempDir(tempDir);
18
18
  });
19
19
 
20
20
  async function writeConfig(yaml: string): Promise<void> {
@@ -428,7 +428,7 @@ describe("validateConfig", () => {
428
428
  });
429
429
 
430
430
  afterEach(async () => {
431
- await rm(tempDir, { recursive: true, force: true });
431
+ await cleanupTempDir(tempDir);
432
432
  });
433
433
 
434
434
  async function writeConfig(yaml: string): Promise<void> {
@@ -6,9 +6,10 @@
6
6
  */
7
7
 
8
8
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
- import { mkdir, mkdtemp, rm } from "node:fs/promises";
9
+ import { mkdir, mkdtemp } from "node:fs/promises";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
+ import { cleanupTempDir } from "../test-helpers.ts";
12
13
  import type { OverstoryConfig } from "../types.ts";
13
14
  import { checkAgents } from "./agents.ts";
14
15
 
@@ -74,7 +75,7 @@ describe("checkAgents", () => {
74
75
  });
75
76
 
76
77
  afterEach(async () => {
77
- await rm(tempDir, { recursive: true, force: true });
78
+ await cleanupTempDir(tempDir);
78
79
  });
79
80
 
80
81
  test("fails when manifest is missing", async () => {
@@ -6,9 +6,10 @@
6
6
  */
7
7
 
8
8
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
9
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
+ import { cleanupTempDir } from "../test-helpers.ts";
12
13
  import type { OverstoryConfig } from "../types.ts";
13
14
  import { checkLogs } from "./logs.ts";
14
15
 
@@ -77,7 +78,7 @@ describe("checkLogs", () => {
77
78
  });
78
79
 
79
80
  afterEach(async () => {
80
- await rm(tempDir, { recursive: true, force: true });
81
+ await cleanupTempDir(tempDir);
81
82
  });
82
83
 
83
84
  test("warns when logs/ directory does not exist", async () => {
@@ -6,9 +6,10 @@
6
6
  */
7
7
 
8
8
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
- import { mkdir, mkdtemp, rm, utimes } from "node:fs/promises";
9
+ import { mkdir, mkdtemp, utimes } from "node:fs/promises";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
+ import { cleanupTempDir } from "../test-helpers.ts";
12
13
  import type { OverstoryConfig } from "../types.ts";
13
14
  import { checkStructure } from "./structure.ts";
14
15
 
@@ -73,7 +74,7 @@ describe("checkStructure", () => {
73
74
  });
74
75
 
75
76
  afterEach(async () => {
76
- await rm(tempDir, { recursive: true, force: true });
77
+ await cleanupTempDir(tempDir);
77
78
  });
78
79
 
79
80
  test("fails when .overstory/ directory does not exist", async () => {
package/src/index.ts CHANGED
@@ -45,7 +45,7 @@ import { OverstoryError, WorktreeError } from "./errors.ts";
45
45
  import { jsonError } from "./json.ts";
46
46
  import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
47
47
 
48
- export const VERSION = "0.7.2";
48
+ export const VERSION = "0.7.3";
49
49
 
50
50
  const rawArgs = process.argv.slice(2);
51
51
 
@@ -96,6 +96,7 @@ const COMMANDS = [
96
96
  "costs",
97
97
  "metrics",
98
98
  "upgrade",
99
+ "completions",
99
100
  ];
100
101
 
101
102
  function editDistance(a: string, b: string): number {
@@ -254,6 +255,7 @@ program
254
255
  .option("--force-hierarchy", "Bypass hierarchy validation")
255
256
  .option("--max-agents <n>", "Max children per lead (overrides config)")
256
257
  .option("--skip-review", "Skip review phase for lead agents")
258
+ .option("--no-scout-check", "Suppress the parentHasScouts scout-before-build warning")
257
259
  .option("--dispatch-max-agents <n>", "Per-lead max agents ceiling (injected into overlay)")
258
260
  .option("--runtime <name>", "Runtime adapter (default: config or claude)")
259
261
  .option("--json", "Output result as JSON")
@@ -10,7 +10,7 @@ import chalk from "chalk";
10
10
  // --- Brand palette (os-eco brand colors) ---
11
11
 
12
12
  /** Forest green — Overstory primary brand color. */
13
- export const brand = chalk.rgb(27, 94, 32);
13
+ export const brand = chalk.rgb(46, 125, 50);
14
14
 
15
15
  /** Amber — highlights, warnings. */
16
16
  export const accent = chalk.rgb(255, 183, 77);
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { StoredEvent } from "../types.ts";
3
+ import { stripAnsi } from "./color.ts";
4
+ import { formatEventLine, numericPriorityColor } from "./format.ts";
5
+
6
+ // Minimal StoredEvent fixture for testing formatEventLine
7
+ const BASE_EVENT: StoredEvent = {
8
+ id: 1,
9
+ runId: "run-001",
10
+ agentName: "agent1",
11
+ sessionId: null,
12
+ eventType: "tool_start",
13
+ level: "info",
14
+ toolName: "bash",
15
+ toolArgs: null,
16
+ toolDurationMs: null,
17
+ data: null,
18
+ createdAt: "2024-01-15T10:30:45.000Z",
19
+ };
20
+
21
+ describe("numericPriorityColor", () => {
22
+ test("returns a function for priority 1", () => {
23
+ const fn = numericPriorityColor(1);
24
+ expect(typeof fn).toBe("function");
25
+ // Function must accept a string and return a string
26
+ expect(typeof fn("x")).toBe("string");
27
+ });
28
+
29
+ test("returns a function for priority 2", () => {
30
+ const fn = numericPriorityColor(2);
31
+ expect(typeof fn).toBe("function");
32
+ expect(typeof fn("x")).toBe("string");
33
+ });
34
+
35
+ test("priority 3 is identity (returns input unchanged)", () => {
36
+ const fn = numericPriorityColor(3);
37
+ // Priority 3 = normal = (text) => text, always identity regardless of chalk level
38
+ expect(fn("x")).toBe("x");
39
+ expect(fn("hello world")).toBe("hello world");
40
+ });
41
+
42
+ test("returns a function for priority 4", () => {
43
+ const fn = numericPriorityColor(4);
44
+ expect(typeof fn).toBe("function");
45
+ expect(typeof fn("x")).toBe("string");
46
+ });
47
+
48
+ test("unknown priority returns identity function", () => {
49
+ const fn = numericPriorityColor(99);
50
+ expect(typeof fn).toBe("function");
51
+ expect(fn("x")).toBe("x");
52
+ });
53
+
54
+ test("all priority functions preserve input text (visible content after strip)", () => {
55
+ for (const p of [1, 2, 3, 4]) {
56
+ const fn = numericPriorityColor(p);
57
+ expect(stripAnsi(fn("hello"))).toBe("hello");
58
+ }
59
+ });
60
+ });
61
+
62
+ describe("formatEventLine", () => {
63
+ test("returns a non-empty string", () => {
64
+ const colorMap = new Map<string, (t: string) => string>();
65
+ const result = formatEventLine(BASE_EVENT, colorMap);
66
+ expect(result.length).toBeGreaterThan(0);
67
+ });
68
+
69
+ test("includes agent name in the result", () => {
70
+ const colorMap = new Map<string, (t: string) => string>();
71
+ const result = formatEventLine(BASE_EVENT, colorMap);
72
+ // Strip ANSI so we can do plain text comparison
73
+ expect(stripAnsi(result)).toContain("agent1");
74
+ });
75
+
76
+ test("includes time portion (10:30:45) in the result", () => {
77
+ const colorMap = new Map<string, (t: string) => string>();
78
+ const result = formatEventLine(BASE_EVENT, colorMap);
79
+ expect(stripAnsi(result)).toContain("10:30:45");
80
+ });
81
+
82
+ test("result does NOT end with a newline", () => {
83
+ const colorMap = new Map<string, (t: string) => string>();
84
+ const result = formatEventLine(BASE_EVENT, colorMap);
85
+ expect(result.endsWith("\n")).toBe(false);
86
+ });
87
+
88
+ test("error level result is a non-empty string", () => {
89
+ const colorMap = new Map<string, (t: string) => string>();
90
+ const errorEvent: StoredEvent = { ...BASE_EVENT, level: "error" };
91
+ const result = formatEventLine(errorEvent, colorMap);
92
+ expect(result.length).toBeGreaterThan(0);
93
+ expect(result.endsWith("\n")).toBe(false);
94
+ });
95
+
96
+ test("uses color from colorMap when agent is registered", () => {
97
+ const blueColor = (t: string) => `\x1b[34m${t}\x1b[39m`;
98
+ const colorMap = new Map<string, (t: string) => string>([["agent1", blueColor]]);
99
+ const result = formatEventLine(BASE_EVENT, colorMap);
100
+ // The colored agent name should appear in the output
101
+ expect(result).toContain("\x1b[34m");
102
+ });
103
+
104
+ test("falls back to gray when agent is not in colorMap", () => {
105
+ const colorMap = new Map<string, (t: string) => string>();
106
+ // Should not throw — gray fallback is used
107
+ const result = formatEventLine(BASE_EVENT, colorMap);
108
+ expect(result.length).toBeGreaterThan(0);
109
+ });
110
+ });
@@ -8,7 +8,7 @@
8
8
  import type { StoredEvent } from "../types.ts";
9
9
  import type { ColorFn } from "./color.ts";
10
10
  import { color, noColor } from "./color.ts";
11
- import { AGENT_COLORS } from "./theme.ts";
11
+ import { AGENT_COLORS, eventLabel } from "./theme.ts";
12
12
 
13
13
  // === Duration ===
14
14
 
@@ -175,6 +175,25 @@ export function priorityColor(priority: string): ColorFn {
175
175
  }
176
176
  }
177
177
 
178
+ /**
179
+ * Returns a color function for a numeric tracker priority.
180
+ * 1=urgent (red), 2=high (yellow), 3=normal (identity), 4=low (dim)
181
+ */
182
+ export function numericPriorityColor(priority: number): ColorFn {
183
+ switch (priority) {
184
+ case 1:
185
+ return color.red;
186
+ case 2:
187
+ return color.yellow;
188
+ case 3:
189
+ return (text: string) => text;
190
+ case 4:
191
+ return color.dim;
192
+ default:
193
+ return (text: string) => text;
194
+ }
195
+ }
196
+
178
197
  /**
179
198
  * Returns a color function for a log level string.
180
199
  * debug=gray, info=blue, warn=yellow, error=red
@@ -212,3 +231,25 @@ export function logLevelLabel(level: string): string {
212
231
  return level.slice(0, 3).toUpperCase();
213
232
  }
214
233
  }
234
+
235
+ /**
236
+ * Format a single event as a compact feed line.
237
+ * Returns the formatted string WITHOUT a trailing newline.
238
+ * Used by both ov feed and the dashboard Feed panel.
239
+ */
240
+ export function formatEventLine(event: StoredEvent, colorMap: Map<string, ColorFn>): string {
241
+ const timeStr = formatAbsoluteTime(event.createdAt);
242
+ const label = eventLabel(event.eventType);
243
+ const levelColorFn =
244
+ event.level === "error" ? color.red : event.level === "warn" ? color.yellow : null;
245
+ const applyLevel = (text: string) => (levelColorFn ? levelColorFn(text) : text);
246
+ const detail = buildEventDetail(event, 60);
247
+ const detailSuffix = detail ? ` ${color.dim(detail)}` : "";
248
+ const agentColorFn = colorMap.get(event.agentName) ?? color.gray;
249
+ const agentLabel = ` ${agentColorFn(event.agentName.padEnd(15))}`;
250
+ return (
251
+ `${color.dim(timeStr)} ` +
252
+ `${applyLevel(label.color(color.bold(label.compact)))}` +
253
+ `${agentLabel}${detailSuffix}`
254
+ );
255
+ }
@@ -1,7 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
- import { access, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
2
+ import { access, mkdtemp, readdir, readFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { cleanupTempDir } from "../test-helpers.ts";
5
6
  import type { LogEvent } from "../types.ts";
6
7
  import { createLogger } from "./logger.ts";
7
8
 
@@ -15,7 +16,7 @@ describe("createLogger", () => {
15
16
  });
16
17
 
17
18
  afterEach(async () => {
18
- await rm(tempDir, { recursive: true, force: true });
19
+ await cleanupTempDir(tempDir);
19
20
  });
20
21
 
21
22
  async function readLogFile(filename: string): Promise<string> {
@@ -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 { MailError } from "../errors.ts";
6
+ import { cleanupTempDir } from "../test-helpers.ts";
6
7
  import type { WorkerDonePayload } from "../types.ts";
7
8
  import { createMailClient, type MailClient, parsePayload } from "./client.ts";
8
9
  import { createMailStore, type MailStore } from "./store.ts";
@@ -20,7 +21,7 @@ describe("createMailClient", () => {
20
21
 
21
22
  afterEach(async () => {
22
23
  client.close();
23
- await rm(tempDir, { recursive: true, force: true });
24
+ await cleanupTempDir(tempDir);
24
25
  });
25
26
 
26
27
  describe("send", () => {
@@ -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 { MailError } from "../errors.ts";
6
+ import { cleanupTempDir } from "../test-helpers.ts";
6
7
  import type { MailMessage } from "../types.ts";
7
8
  import { createMailStore, type MailStore } from "./store.ts";
8
9
 
@@ -17,7 +18,7 @@ describe("createMailStore", () => {
17
18
 
18
19
  afterEach(async () => {
19
20
  store.close();
20
- await rm(tempDir, { recursive: true, force: true });
21
+ await cleanupTempDir(tempDir);
21
22
  });
22
23
 
23
24
  describe("insert", () => {
@@ -1,9 +1,10 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
3
- import { mkdtemp, rm } from "node:fs/promises";
3
+ import { mkdtemp } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { MergeError } from "../errors.ts";
7
+ import { cleanupTempDir } from "../test-helpers.ts";
7
8
  import { createMergeQueue } from "./queue.ts";
8
9
 
9
10
  describe("createMergeQueue", () => {
@@ -17,7 +18,7 @@ describe("createMergeQueue", () => {
17
18
  });
18
19
 
19
20
  afterEach(async () => {
20
- await rm(tempDir, { recursive: true, force: true });
21
+ await cleanupTempDir(tempDir);
21
22
  });
22
23
 
23
24
  function makeInput(