@katyella/legio 0.1.3 → 0.2.2

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 (112) hide show
  1. package/CHANGELOG.md +61 -3
  2. package/README.md +21 -10
  3. package/agents/builder.md +11 -10
  4. package/agents/coordinator.md +36 -27
  5. package/agents/cto.md +9 -8
  6. package/agents/gateway.md +28 -12
  7. package/agents/lead.md +45 -30
  8. package/agents/merger.md +4 -4
  9. package/agents/monitor.md +10 -9
  10. package/agents/reviewer.md +8 -8
  11. package/agents/scout.md +10 -10
  12. package/agents/supervisor.md +60 -45
  13. package/package.json +2 -2
  14. package/src/agents/hooks-deployer.test.ts +46 -41
  15. package/src/agents/hooks-deployer.ts +10 -9
  16. package/src/agents/manifest.test.ts +6 -2
  17. package/src/agents/overlay.test.ts +9 -7
  18. package/src/agents/overlay.ts +29 -7
  19. package/src/commands/agents.test.ts +1 -5
  20. package/src/commands/clean.test.ts +2 -5
  21. package/src/commands/clean.ts +25 -1
  22. package/src/commands/completions.test.ts +1 -1
  23. package/src/commands/completions.ts +26 -7
  24. package/src/commands/coordinator.test.ts +87 -82
  25. package/src/commands/coordinator.ts +94 -48
  26. package/src/commands/costs.test.ts +2 -6
  27. package/src/commands/dashboard.test.ts +2 -5
  28. package/src/commands/doctor.test.ts +2 -6
  29. package/src/commands/down.ts +3 -3
  30. package/src/commands/errors.test.ts +2 -6
  31. package/src/commands/feed.test.ts +2 -6
  32. package/src/commands/gateway.test.ts +43 -17
  33. package/src/commands/gateway.ts +101 -11
  34. package/src/commands/hooks.test.ts +2 -5
  35. package/src/commands/init.test.ts +4 -13
  36. package/src/commands/inspect.test.ts +2 -6
  37. package/src/commands/log.test.ts +2 -6
  38. package/src/commands/logs.test.ts +2 -9
  39. package/src/commands/mail.test.ts +76 -215
  40. package/src/commands/mail.ts +43 -187
  41. package/src/commands/metrics.test.ts +3 -10
  42. package/src/commands/nudge.ts +15 -0
  43. package/src/commands/prime.test.ts +4 -11
  44. package/src/commands/replay.test.ts +2 -6
  45. package/src/commands/server.test.ts +1 -5
  46. package/src/commands/server.ts +1 -1
  47. package/src/commands/sling.test.ts +6 -1
  48. package/src/commands/sling.ts +42 -17
  49. package/src/commands/spec.test.ts +2 -5
  50. package/src/commands/status.test.ts +2 -4
  51. package/src/commands/stop.test.ts +2 -5
  52. package/src/commands/supervisor.ts +6 -6
  53. package/src/commands/trace.test.ts +2 -6
  54. package/src/commands/up.test.ts +43 -9
  55. package/src/commands/up.ts +15 -11
  56. package/src/commands/watchman.ts +327 -0
  57. package/src/commands/worktree.test.ts +2 -6
  58. package/src/config.test.ts +34 -104
  59. package/src/config.ts +120 -32
  60. package/src/doctor/agents.test.ts +52 -2
  61. package/src/doctor/agents.ts +4 -2
  62. package/src/doctor/config-check.test.ts +7 -2
  63. package/src/doctor/consistency.test.ts +7 -2
  64. package/src/doctor/databases.test.ts +6 -2
  65. package/src/doctor/dependencies.test.ts +18 -13
  66. package/src/doctor/dependencies.ts +23 -94
  67. package/src/doctor/logs.test.ts +7 -2
  68. package/src/doctor/merge-queue.test.ts +6 -2
  69. package/src/doctor/structure.test.ts +7 -2
  70. package/src/doctor/version.test.ts +7 -2
  71. package/src/e2e/init-sling-lifecycle.test.ts +2 -5
  72. package/src/index.ts +7 -7
  73. package/src/mail/pending.ts +120 -0
  74. package/src/mail/store.test.ts +89 -0
  75. package/src/mail/store.ts +11 -0
  76. package/src/merge/resolver.test.ts +518 -489
  77. package/src/server/index.ts +33 -2
  78. package/src/server/public/app.js +3 -3
  79. package/src/server/public/components/message-bubble.js +11 -1
  80. package/src/server/public/components/terminal-panel.js +66 -74
  81. package/src/server/public/views/chat.js +18 -2
  82. package/src/server/public/views/costs.js +5 -5
  83. package/src/server/public/views/dashboard.js +80 -51
  84. package/src/server/public/views/gateway-chat.js +37 -131
  85. package/src/server/public/views/inspect.js +16 -4
  86. package/src/server/public/views/issues.js +16 -12
  87. package/src/server/routes.test.ts +55 -39
  88. package/src/server/routes.ts +38 -26
  89. package/src/test-helpers.ts +6 -3
  90. package/src/tracker/beads.ts +159 -0
  91. package/src/tracker/exec.ts +44 -0
  92. package/src/tracker/factory.test.ts +283 -0
  93. package/src/tracker/factory.ts +59 -0
  94. package/src/tracker/seeds.ts +156 -0
  95. package/src/tracker/types.ts +46 -0
  96. package/src/types.ts +11 -2
  97. package/src/{watchdog → watchman}/daemon.test.ts +421 -515
  98. package/src/watchman/daemon.ts +940 -0
  99. package/src/worktree/tmux.test.ts +2 -1
  100. package/src/worktree/tmux.ts +4 -4
  101. package/templates/hooks.json.tmpl +17 -17
  102. package/src/beads/client.test.ts +0 -210
  103. package/src/commands/merge.test.ts +0 -676
  104. package/src/commands/watch.test.ts +0 -152
  105. package/src/commands/watch.ts +0 -238
  106. package/src/test-helpers.test.ts +0 -97
  107. package/src/watchdog/daemon.ts +0 -533
  108. package/src/watchdog/health.test.ts +0 -371
  109. package/src/watchdog/triage.test.ts +0 -162
  110. package/src/worktree/manager.test.ts +0 -444
  111. /package/src/{watchdog → watchman}/health.ts +0 -0
  112. /package/src/{watchdog → watchman}/triage.ts +0 -0
@@ -8,7 +8,7 @@
8
8
  import { access, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
- import { afterEach, beforeEach, describe, expect, test } from "vitest";
11
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
12
12
  import { createEventStore } from "../events/store.ts";
13
13
  import { createMailClient } from "../mail/client.ts";
14
14
  import { createMailStore } from "../mail/store.ts";
@@ -17,7 +17,6 @@ import { mailCommand } from "./mail.ts";
17
17
 
18
18
  describe("mailCommand", () => {
19
19
  let tempDir: string;
20
- let origCwd: string;
21
20
  let origWrite: typeof process.stdout.write;
22
21
  let origStderrWrite: typeof process.stderr.write;
23
22
  let output: string;
@@ -44,9 +43,7 @@ describe("mailCommand", () => {
44
43
  });
45
44
  client.close();
46
45
 
47
- // Change cwd to temp dir so the command finds .legio/mail.db
48
- origCwd = process.cwd();
49
- process.chdir(tempDir);
46
+ vi.spyOn(process, "cwd").mockReturnValue(tempDir);
50
47
 
51
48
  // Capture stdout
52
49
  output = "";
@@ -68,7 +65,6 @@ describe("mailCommand", () => {
68
65
  afterEach(async () => {
69
66
  process.stdout.write = origWrite;
70
67
  process.stderr.write = origStderrWrite;
71
- process.chdir(origCwd);
72
68
  await rm(tempDir, { recursive: true, force: true });
73
69
  });
74
70
 
@@ -546,240 +542,105 @@ describe("mailCommand", () => {
546
542
  });
547
543
  });
548
544
 
549
- describe("mail check debounce", () => {
550
- test("mail check without --debounce flag always executes", async () => {
551
- // Send first message
552
- const store = createMailStore(join(tempDir, ".legio", "mail.db"));
553
- const client = createMailClient(store);
554
- client.send({
555
- from: "orchestrator",
556
- to: "test-agent",
557
- subject: "Message 1",
558
- body: "First message",
559
- });
560
- client.close();
561
-
562
- // First check
545
+ describe("mail check --signal", () => {
546
+ test("--signal skips DB query when no signal file exists", async () => {
563
547
  output = "";
564
- await mailCommand(["check", "--inject", "--agent", "test-agent"]);
565
- const firstOutput = output;
566
-
567
- // Send second message
568
- const store2 = createMailStore(join(tempDir, ".legio", "mail.db"));
569
- const client2 = createMailClient(store2);
570
- client2.send({
571
- from: "orchestrator",
572
- to: "test-agent",
573
- subject: "Message 2",
574
- body: "Second message",
575
- });
576
- client2.close();
577
-
578
- // Second check immediately after
579
- output = "";
580
- await mailCommand(["check", "--inject", "--agent", "test-agent"]);
581
- const secondOutput = output;
582
-
583
- // Both should execute (no debouncing without flag)
584
- expect(firstOutput).toContain("Message 1");
585
- expect(secondOutput).toContain("Message 2");
586
- });
587
-
588
- test("mail check with --debounce skips second check within window", async () => {
589
- // First check with debounce (large window to survive concurrency)
590
- output = "";
591
- await mailCommand(["check", "--agent", "builder-1", "--debounce", "10000"]);
592
- expect(output).toContain("Build task");
593
-
594
- // Second check immediately (within debounce window)
595
- output = "";
596
- await mailCommand(["check", "--agent", "builder-1", "--debounce", "10000"]);
597
- // Should be skipped silently
548
+ await mailCommand(["check", "--inject", "--agent", "builder-1", "--signal"]);
549
+ // No signal file = no output, no DB query
598
550
  expect(output).toBe("");
599
551
  });
600
552
 
601
- test("mail check with --debounce allows check after window expires", async () => {
602
- // Send first message
603
- const store = createMailStore(join(tempDir, ".legio", "mail.db"));
604
- const client = createMailClient(store);
605
- client.send({
606
- from: "orchestrator",
607
- to: "debounce-test",
608
- subject: "First",
609
- body: "First check",
610
- });
611
- client.close();
612
-
613
- // First check with debounce
614
- output = "";
615
- await mailCommand(["check", "--inject", "--agent", "debounce-test", "--debounce", "100"]);
616
- expect(output).toContain("First check");
617
-
618
- // Wait for debounce window to expire
619
- await new Promise((resolve) => setTimeout(resolve, 150));
620
-
621
- // Send second message
622
- const store2 = createMailStore(join(tempDir, ".legio", "mail.db"));
623
- const client2 = createMailClient(store2);
624
- client2.send({
625
- from: "orchestrator",
626
- to: "debounce-test",
627
- subject: "Second",
628
- body: "Second check",
629
- });
630
- client2.close();
553
+ test("--signal queries DB and injects when signal file exists", async () => {
554
+ // Write a signal file for builder-1
555
+ const nudgeDir = join(tempDir, ".legio", "pending-nudges");
556
+ await mkdir(nudgeDir, { recursive: true });
557
+ await writeFile(
558
+ join(nudgeDir, "builder-1.json"),
559
+ JSON.stringify({
560
+ from: "orchestrator",
561
+ reason: "status",
562
+ subject: "Build task",
563
+ messageId: "test-msg-1",
564
+ createdAt: new Date().toISOString(),
565
+ }),
566
+ );
631
567
 
632
- // Second check after debounce window
633
568
  output = "";
634
- await mailCommand(["check", "--inject", "--agent", "debounce-test", "--debounce", "100"]);
635
- expect(output).toContain("Second check");
569
+ await mailCommand(["check", "--inject", "--agent", "builder-1", "--signal"]);
570
+ // Should inject the message AND show priority banner
571
+ expect(output).toContain("Build task");
572
+ expect(output).toContain("PRIORITY");
636
573
  });
637
574
 
638
- test("mail check with --debounce 0 disables debouncing", async () => {
639
- // Send first message
640
- const store = createMailStore(join(tempDir, ".legio", "mail.db"));
641
- const client = createMailClient(store);
642
- client.send({
643
- from: "orchestrator",
644
- to: "zero-debounce",
645
- subject: "Msg 1",
646
- body: "Message one",
647
- });
648
- client.close();
649
-
650
- // First check with --debounce 0
651
- output = "";
652
- await mailCommand(["check", "--inject", "--agent", "zero-debounce", "--debounce", "0"]);
653
- expect(output).toContain("Message one");
575
+ test("--signal clears signal file after check", async () => {
576
+ // Write a signal file
577
+ const nudgeDir = join(tempDir, ".legio", "pending-nudges");
578
+ await mkdir(nudgeDir, { recursive: true });
579
+ const signalPath = join(nudgeDir, "builder-1.json");
580
+ await writeFile(
581
+ signalPath,
582
+ JSON.stringify({
583
+ from: "orchestrator",
584
+ reason: "status",
585
+ subject: "Build task",
586
+ messageId: "test-msg-1",
587
+ createdAt: new Date().toISOString(),
588
+ }),
589
+ );
654
590
 
655
- // Send second message immediately
656
- const store2 = createMailStore(join(tempDir, ".legio", "mail.db"));
657
- const client2 = createMailClient(store2);
658
- client2.send({
659
- from: "orchestrator",
660
- to: "zero-debounce",
661
- subject: "Msg 2",
662
- body: "Message two",
663
- });
664
- client2.close();
591
+ await mailCommand(["check", "--inject", "--agent", "builder-1", "--signal"]);
665
592
 
666
- // Second check immediately (should work with debounce 0)
667
- output = "";
668
- await mailCommand(["check", "--inject", "--agent", "zero-debounce", "--debounce", "0"]);
669
- expect(output).toContain("Message two");
593
+ // Signal file should be cleared by readAndClearPendingNudge
594
+ await expect(access(signalPath)).rejects.toThrow();
670
595
  });
671
596
 
672
- test("mail check debounce is per-agent", async () => {
673
- // Check for builder-1 with debounce (large window to survive concurrency)
674
- output = "";
675
- await mailCommand(["check", "--agent", "builder-1", "--debounce", "10000"]);
676
- expect(output).toContain("Build task");
677
-
678
- // Check for scout-1 immediately (different agent, should NOT be debounced)
679
- output = "";
680
- await mailCommand(["check", "--agent", "scout-1", "--debounce", "10000"]);
681
- expect(output).toContain("Explore API");
682
-
683
- // Check for builder-1 again (should be debounced)
597
+ test("--signal without --inject still respects signal gating", async () => {
598
+ // No signal file should skip
684
599
  output = "";
685
- await mailCommand(["check", "--agent", "builder-1", "--debounce", "10000"]);
600
+ await mailCommand(["check", "--agent", "builder-1", "--signal"]);
686
601
  expect(output).toBe("");
687
- });
688
-
689
- test("mail check --debounce with invalid value throws ValidationError", async () => {
690
- try {
691
- await mailCommand(["check", "--agent", "builder-1", "--debounce", "invalid"]);
692
- expect(true).toBe(false); // Should not reach here
693
- } catch (err) {
694
- expect(err).toBeInstanceOf(Error);
695
- if (err instanceof Error) {
696
- expect(err.message).toContain("must be a non-negative integer");
697
- }
698
- }
699
- });
700
602
 
701
- test("mail check --debounce with negative value throws ValidationError", async () => {
702
- try {
703
- await mailCommand(["check", "--agent", "builder-1", "--debounce", "-100"]);
704
- expect(true).toBe(false);
705
- } catch (err) {
706
- expect(err).toBeInstanceOf(Error);
707
- if (err instanceof Error) {
708
- expect(err.message).toContain("must be a non-negative integer");
709
- }
710
- }
711
- });
603
+ // With signal file should show messages
604
+ const nudgeDir = join(tempDir, ".legio", "pending-nudges");
605
+ await mkdir(nudgeDir, { recursive: true });
606
+ await writeFile(
607
+ join(nudgeDir, "builder-1.json"),
608
+ JSON.stringify({
609
+ from: "orchestrator",
610
+ reason: "status",
611
+ subject: "Build task",
612
+ messageId: "test-msg-1",
613
+ createdAt: new Date().toISOString(),
614
+ }),
615
+ );
712
616
 
713
- test("mail check --inject with --debounce skips check within window", async () => {
714
- // First inject check with debounce
715
617
  output = "";
716
- await mailCommand(["check", "--inject", "--agent", "builder-1", "--debounce", "500"]);
618
+ await mailCommand(["check", "--agent", "builder-1", "--signal"]);
717
619
  expect(output).toContain("Build task");
718
-
719
- // Second inject check immediately (should be debounced)
720
- output = "";
721
- await mailCommand(["check", "--inject", "--agent", "builder-1", "--debounce", "500"]);
722
- expect(output).toBe("");
723
620
  });
724
621
 
725
- test("mail check debounce state persists across invocations", async () => {
726
- // First check
727
- output = "";
622
+ test("--debounce flag emits deprecation warning", async () => {
623
+ stderrOutput = "";
728
624
  await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
729
- expect(output).toContain("Build task");
730
-
731
- // Verify state file was created
732
- const statePath = join(tempDir, ".legio", "mail-check-state.json");
733
- {
734
- let _e = false;
735
- try {
736
- await access(statePath);
737
- _e = true;
738
- } catch {}
739
- expect(_e).toBe(true);
740
- }
741
-
742
- const state = JSON.parse(await readFile(statePath, "utf-8")) as Record<string, number>;
743
- expect(state["builder-1"]).toBeTruthy();
744
- expect(typeof state["builder-1"]).toBe("number");
625
+ expect(stderrOutput).toContain("--debounce is deprecated");
745
626
  });
746
627
 
747
- test("corrupted debounce state file is handled gracefully", async () => {
748
- // Write corrupted state file
749
- const statePath = join(tempDir, ".legio", "mail-check-state.json");
750
- await writeFile(statePath, "not valid json");
628
+ test("without --signal flag always executes (backward compat)", async () => {
629
+ // Send first message
630
+ const store = createMailStore(join(tempDir, ".legio", "mail.db"));
631
+ const client = createMailClient(store);
632
+ client.send({
633
+ from: "orchestrator",
634
+ to: "test-agent",
635
+ subject: "Message 1",
636
+ body: "First message",
637
+ });
638
+ client.close();
751
639
 
752
- // Should not throw, should treat as fresh state
640
+ // Check without --signal flag always queries DB
753
641
  output = "";
754
- await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
755
- expect(output).toContain("Build task");
756
-
757
- // State should be corrected
758
- const state = JSON.parse(await readFile(statePath, "utf-8")) as Record<string, number>;
759
- expect(state["builder-1"]).toBeTruthy();
760
- });
761
-
762
- test("mail check debounce only records timestamp when flag is provided", async () => {
763
- const statePath = join(tempDir, ".legio", "mail-check-state.json");
764
-
765
- // Check without debounce flag
766
- await mailCommand(["check", "--agent", "builder-1"]);
767
-
768
- // State file should not be created
769
- await expect(access(statePath)).rejects.toThrow();
770
-
771
- // Check with debounce flag
772
- await mailCommand(["check", "--agent", "builder-1", "--debounce", "500"]);
773
-
774
- // Now state file should exist
775
- {
776
- let _e = false;
777
- try {
778
- await access(statePath);
779
- _e = true;
780
- } catch {}
781
- expect(_e).toBe(true);
782
- }
642
+ await mailCommand(["check", "--inject", "--agent", "test-agent"]);
643
+ expect(output).toContain("Message 1");
783
644
  });
784
645
  });
785
646
 
@@ -6,13 +6,19 @@
6
6
  * and various filters for listing messages.
7
7
  */
8
8
 
9
- import { access, readFile, writeFile } from "node:fs/promises";
9
+ import { access, readFile } from "node:fs/promises";
10
10
  import { join } from "node:path";
11
11
  import { resolveProjectRoot } from "../config.ts";
12
12
  import { MailError, ValidationError } from "../errors.ts";
13
13
  import { createEventStore } from "../events/store.ts";
14
14
  import { isGroupAddress, resolveGroupAddress } from "../mail/broadcast.ts";
15
15
  import { createMailClient } from "../mail/client.ts";
16
+ import {
17
+ isAgentIdle,
18
+ pendingNudgeDir,
19
+ readAndClearPendingNudge,
20
+ writePendingNudge,
21
+ } from "../mail/pending.ts";
16
22
  import { createMailStore } from "../mail/store.ts";
17
23
  import { openSessionStore } from "../sessions/compat.ts";
18
24
  import type { MailAudience, MailMessage } from "../types.ts";
@@ -56,7 +62,15 @@ function hasFlag(args: string[], flag: string): boolean {
56
62
  }
57
63
 
58
64
  /** Boolean flags that do NOT consume the next arg as a value. */
59
- const BOOLEAN_FLAGS = new Set(["--json", "--inject", "--unread", "--all", "--help", "-h"]);
65
+ const BOOLEAN_FLAGS = new Set([
66
+ "--json",
67
+ "--inject",
68
+ "--unread",
69
+ "--all",
70
+ "--signal",
71
+ "--help",
72
+ "-h",
73
+ ]);
60
74
 
61
75
  /**
62
76
  * Extract positional arguments from an args array, skipping flag-value pairs.
@@ -141,165 +155,6 @@ function openStore(cwd: string) {
141
155
  return createMailStore(dbPath);
142
156
  }
143
157
 
144
- // === Pending Nudge Markers ===
145
- //
146
- // Instead of sending tmux keys (which corrupt tool I/O), auto-nudge writes
147
- // a JSON marker file per agent. The `mail check --inject` flow reads and
148
- // clears these markers, prepending a priority banner to the injected output.
149
-
150
- /** Directory where pending nudge markers are stored. */
151
- function pendingNudgeDir(cwd: string): string {
152
- return join(cwd, ".legio", "pending-nudges");
153
- }
154
-
155
- /**
156
- * Check if an agent is idle (not actively executing a tool).
157
- *
158
- * An agent is considered idle when `.legio/agent-busy/{agentName}` does NOT exist.
159
- * The busy marker is written by hooks during active tool execution and removed when idle.
160
- * Idle agents can receive a direct tmux nudge; busy agents only get the pending marker.
161
- */
162
- async function isAgentIdle(cwd: string, agentName: string): Promise<boolean> {
163
- const busyPath = join(cwd, ".legio", "agent-busy", agentName);
164
- try {
165
- await access(busyPath);
166
- return false; // busy marker present — agent is actively working
167
- } catch {
168
- return true; // no busy marker — agent is idle
169
- }
170
- }
171
-
172
- /** Shape of a pending nudge marker file. */
173
- interface PendingNudge {
174
- from: string;
175
- reason: string;
176
- subject: string;
177
- messageId: string;
178
- createdAt: string;
179
- }
180
-
181
- /**
182
- * Write a pending nudge marker for an agent.
183
- *
184
- * Creates `.legio/pending-nudges/{agent}.json` so that the next
185
- * `mail check --inject` call surfaces a priority banner for this message.
186
- * Overwrites any existing marker (only the latest nudge matters).
187
- */
188
- async function writePendingNudge(
189
- cwd: string,
190
- agentName: string,
191
- nudge: Omit<PendingNudge, "createdAt">,
192
- ): Promise<void> {
193
- const dir = pendingNudgeDir(cwd);
194
- const { mkdir } = await import("node:fs/promises");
195
- await mkdir(dir, { recursive: true });
196
-
197
- const marker: PendingNudge = {
198
- ...nudge,
199
- createdAt: new Date().toISOString(),
200
- };
201
- const filePath = join(dir, `${agentName}.json`);
202
- await writeFile(filePath, `${JSON.stringify(marker, null, "\t")}\n`);
203
- }
204
-
205
- /**
206
- * Read and clear pending nudge markers for an agent.
207
- *
208
- * Returns the pending nudge (if any) and removes the marker file.
209
- * Called by `mail check --inject` to prepend a priority banner.
210
- */
211
- async function readAndClearPendingNudge(
212
- cwd: string,
213
- agentName: string,
214
- ): Promise<PendingNudge | null> {
215
- const filePath = join(pendingNudgeDir(cwd), `${agentName}.json`);
216
- try {
217
- await access(filePath);
218
- } catch {
219
- return null;
220
- }
221
- try {
222
- const text = await readFile(filePath, "utf-8");
223
- const nudge = JSON.parse(text) as PendingNudge;
224
- const { unlink } = await import("node:fs/promises");
225
- await unlink(filePath);
226
- return nudge;
227
- } catch {
228
- // Corrupt or race condition — clear it and move on
229
- try {
230
- const { unlink } = await import("node:fs/promises");
231
- await unlink(filePath);
232
- } catch {
233
- // Already gone
234
- }
235
- return null;
236
- }
237
- }
238
-
239
- // === Mail Check Debounce ===
240
- //
241
- // Prevents excessive mail checking by tracking the last check timestamp per agent.
242
- // When --debounce flag is provided, mail check will skip if called within the
243
- // debounce window.
244
-
245
- /**
246
- * Path to the mail check debounce state file.
247
- */
248
- function mailCheckStatePath(cwd: string): string {
249
- return join(cwd, ".legio", "mail-check-state.json");
250
- }
251
-
252
- /**
253
- * Check if a mail check for this agent is within the debounce window.
254
- *
255
- * @param cwd - Project root directory
256
- * @param agentName - Agent name
257
- * @param debounceMs - Debounce interval in milliseconds
258
- * @returns true if the last check was within the debounce window
259
- */
260
- async function isMailCheckDebounced(
261
- cwd: string,
262
- agentName: string,
263
- debounceMs: number,
264
- ): Promise<boolean> {
265
- const statePath = mailCheckStatePath(cwd);
266
- try {
267
- await access(statePath);
268
- } catch {
269
- return false;
270
- }
271
- try {
272
- const text = await readFile(statePath, "utf-8");
273
- const state = JSON.parse(text) as Record<string, number>;
274
- const lastCheck = state[agentName];
275
- if (lastCheck === undefined) {
276
- return false;
277
- }
278
- return Date.now() - lastCheck < debounceMs;
279
- } catch {
280
- return false;
281
- }
282
- }
283
-
284
- /**
285
- * Record a mail check timestamp for debounce tracking.
286
- *
287
- * @param cwd - Project root directory
288
- * @param agentName - Agent name
289
- */
290
- async function recordMailCheck(cwd: string, agentName: string): Promise<void> {
291
- const statePath = mailCheckStatePath(cwd);
292
- let state: Record<string, number> = {};
293
- try {
294
- const text = await readFile(statePath, "utf-8");
295
- state = JSON.parse(text) as Record<string, number>;
296
- } catch {
297
- // File does not exist or corrupt state — start fresh
298
- }
299
- state[agentName] = Date.now();
300
- await writeFile(statePath, `${JSON.stringify(state, null, "\t")}\n`);
301
- }
302
-
303
158
  /**
304
159
  * Open a mail client connected to the project's mail.db.
305
160
  * The cwd must already be resolved to the canonical project root.
@@ -336,7 +191,14 @@ async function handleSend(args: string[], cwd: string): Promise<void> {
336
191
  }
337
192
 
338
193
  const type = rawType as MailMessage["type"];
339
- const priority = rawPriority as MailMessage["priority"];
194
+ let priority = rawPriority as MailMessage["priority"];
195
+
196
+ // escalation and dispatch default to high priority when no explicit --priority was given
197
+ const HIGH_PRIORITY_DEFAULT_TYPES = new Set(["escalation", "dispatch"]);
198
+ const explicitPriority = getFlag(args, "--priority") !== undefined;
199
+ if (!explicitPriority && HIGH_PRIORITY_DEFAULT_TYPES.has(type)) {
200
+ priority = "high";
201
+ }
340
202
 
341
203
  // Parse --audience flag (optional, auto-derived from type if not specified)
342
204
  const rawAudience = getFlag(args, "--audience");
@@ -628,8 +490,17 @@ async function handleCheck(args: string[], cwd: string): Promise<void> {
628
490
  const agent = getFlag(args, "--agent") ?? "orchestrator";
629
491
  const inject = hasFlag(args, "--inject");
630
492
  const json = hasFlag(args, "--json");
631
- const debounceFlag = getFlag(args, "--debounce");
493
+ const signal = hasFlag(args, "--signal");
632
494
  const audience = getFlag(args, "--audience");
495
+
496
+ // --debounce is deprecated (no-op). Accept silently for backward compat.
497
+ // The --signal flag replaces debounce — signal files are the authoritative trigger.
498
+ if (getFlag(args, "--debounce") !== undefined) {
499
+ process.stderr.write(
500
+ "⚠️ --debounce is deprecated and ignored. Use --signal for signal-gated mail checks.\n",
501
+ );
502
+ }
503
+
633
504
  if (audience !== undefined && !(VALID_AUDIENCES as readonly string[]).includes(audience)) {
634
505
  throw new ValidationError(
635
506
  `Invalid --audience "${audience}". Must be one of: ${VALID_AUDIENCES.join(", ")}`,
@@ -637,24 +508,14 @@ async function handleCheck(args: string[], cwd: string): Promise<void> {
637
508
  );
638
509
  }
639
510
 
640
- // Parse debounce interval if provided
641
- let debounceMs: number | undefined;
642
- if (debounceFlag !== undefined) {
643
- const parsed = Number.parseInt(debounceFlag, 10);
644
- if (Number.isNaN(parsed) || parsed < 0) {
645
- throw new ValidationError(
646
- `--debounce must be a non-negative integer (milliseconds), got: ${debounceFlag}`,
647
- { field: "debounce", value: debounceFlag },
648
- );
649
- }
650
- debounceMs = parsed;
651
- }
652
-
653
- // Check debounce if enabled
654
- if (debounceMs !== undefined) {
655
- const debounced = await isMailCheckDebounced(cwd, agent, debounceMs);
656
- if (debounced) {
657
- // Silent skip — no output when debounced
511
+ // Signal-gated mode: skip DB query entirely if no signal file exists.
512
+ // The signal file is the pending nudge marker written by `mail send`.
513
+ if (signal) {
514
+ const signalPath = join(pendingNudgeDir(cwd), `${agent}.json`);
515
+ try {
516
+ await access(signalPath);
517
+ } catch {
518
+ // No signal file no new mail. Exit immediately (zero cost).
658
519
  return;
659
520
  }
660
521
  }
@@ -712,11 +573,6 @@ async function handleCheck(args: string[], cwd: string): Promise<void> {
712
573
  }
713
574
  }
714
575
  }
715
-
716
- // Record this check for debounce tracking (only if debounce is enabled)
717
- if (debounceMs !== undefined) {
718
- await recordMailCheck(cwd, agent);
719
- }
720
576
  } finally {
721
577
  client.close();
722
578
  }
@@ -869,7 +725,7 @@ Subcommands:
869
725
  Audience: defaults to 'agent' for protocol types, 'both' for semantic types
870
726
  check Check inbox (unread messages)
871
727
  [--agent <name>] [--audience <human|agent|both>]
872
- [--inject] [--json]
728
+ [--inject] [--signal] [--json]
873
729
  list List messages with filters
874
730
  [--from <name>] [--to <name>] [--agent <name> (alias for --to)]
875
731
  [--audience <human|agent|both>] [--unread] [--json]