@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
@@ -24,16 +24,20 @@ describe("checkMergeQueue", () => {
24
24
  maxDepth: 2,
25
25
  },
26
26
  worktrees: { baseDir: "" },
27
- beads: { enabled: true },
27
+ taskTracker: { backend: "auto" as const, enabled: true },
28
28
  mulch: { enabled: true, domains: [], primeFormat: "markdown" },
29
29
  merge: { aiResolveEnabled: false, reimagineEnabled: false },
30
- watchdog: {
30
+ watchman: {
31
31
  tier0Enabled: true,
32
32
  tier0IntervalMs: 30000,
33
33
  tier1Enabled: false,
34
34
  tier2Enabled: false,
35
35
  zombieThresholdMs: 600000,
36
36
  nudgeIntervalMs: 60000,
37
+ mailIntervalMs: 5_000,
38
+ reNudgeIntervalMs: 10_000,
39
+ warnAfterMs: 60_000,
40
+ beaconNudgeMs: 20_000,
37
41
  },
38
42
  models: {},
39
43
  logging: { verbose: false, redactSecrets: true },
@@ -37,7 +37,8 @@ describe("checkStructure", () => {
37
37
  worktrees: {
38
38
  baseDir: ".legio/worktrees",
39
39
  },
40
- beads: {
40
+ taskTracker: {
41
+ backend: "auto" as const,
41
42
  enabled: true,
42
43
  },
43
44
  mulch: {
@@ -49,13 +50,17 @@ describe("checkStructure", () => {
49
50
  aiResolveEnabled: false,
50
51
  reimagineEnabled: false,
51
52
  },
52
- watchdog: {
53
+ watchman: {
53
54
  tier0Enabled: true,
54
55
  tier0IntervalMs: 30000,
55
56
  tier1Enabled: false,
56
57
  tier2Enabled: false,
57
58
  zombieThresholdMs: 600000,
58
59
  nudgeIntervalMs: 60000,
60
+ mailIntervalMs: 5_000,
61
+ reNudgeIntervalMs: 10_000,
62
+ warnAfterMs: 60_000,
63
+ beaconNudgeMs: 20_000,
59
64
  },
60
65
  models: {},
61
66
  logging: {
@@ -19,7 +19,8 @@ const mockConfig: LegioConfig = {
19
19
  worktrees: {
20
20
  baseDir: "/tmp/.legio/worktrees",
21
21
  },
22
- beads: {
22
+ taskTracker: {
23
+ backend: "auto" as const,
23
24
  enabled: false,
24
25
  },
25
26
  mulch: {
@@ -31,13 +32,17 @@ const mockConfig: LegioConfig = {
31
32
  aiResolveEnabled: false,
32
33
  reimagineEnabled: false,
33
34
  },
34
- watchdog: {
35
+ watchman: {
35
36
  tier0Enabled: false,
36
37
  tier0IntervalMs: 30000,
37
38
  tier1Enabled: false,
38
39
  tier2Enabled: false,
39
40
  zombieThresholdMs: 600000,
40
41
  nudgeIntervalMs: 60000,
42
+ mailIntervalMs: 5_000,
43
+ reNudgeIntervalMs: 10_000,
44
+ warnAfterMs: 60_000,
45
+ beaconNudgeMs: 20_000,
41
46
  },
42
47
  models: {},
43
48
  logging: {
@@ -1,6 +1,6 @@
1
1
  import { access, readdir, readFile, stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { afterEach, beforeEach, describe, expect, test } from "vitest";
3
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
4
4
 
5
5
  /** Test helper: check whether a file exists using Node.js fs/promises. */
6
6
  async function fileExists(path: string): Promise<boolean> {
@@ -48,13 +48,11 @@ const EXPECTED_AGENT_DEFS = [
48
48
 
49
49
  describe("E2E: init→sling lifecycle on external project", () => {
50
50
  let tempDir: string;
51
- let originalCwd: string;
52
51
  let originalWrite: typeof process.stdout.write;
53
52
 
54
53
  beforeEach(async () => {
55
54
  tempDir = await createTempGitRepo();
56
- originalCwd = process.cwd();
57
- process.chdir(tempDir);
55
+ vi.spyOn(process, "cwd").mockReturnValue(tempDir);
58
56
 
59
57
  // Suppress stdout noise from initCommand
60
58
  originalWrite = process.stdout.write;
@@ -62,7 +60,6 @@ describe("E2E: init→sling lifecycle on external project", () => {
62
60
  });
63
61
 
64
62
  afterEach(async () => {
65
- process.chdir(originalCwd);
66
63
  process.stdout.write = originalWrite;
67
64
  await cleanupTempDir(tempDir);
68
65
  });
package/src/index.ts CHANGED
@@ -40,12 +40,12 @@ import { stopCommand } from "./commands/stop.ts";
40
40
  import { supervisorCommand } from "./commands/supervisor.ts";
41
41
  import { traceCommand } from "./commands/trace.ts";
42
42
  import { upCommand } from "./commands/up.ts";
43
- import { watchCommand } from "./commands/watch.ts";
43
+ import { watchmanCommand } from "./commands/watchman.ts";
44
44
  import { worktreeCommand } from "./commands/worktree.ts";
45
45
  import { LegioError, WorktreeError } from "./errors.ts";
46
46
  import { setQuiet } from "./logging/color.ts";
47
47
 
48
- const VERSION = "0.1.3";
48
+ const VERSION = "0.2.2";
49
49
 
50
50
  const HELP = `legio v${VERSION} — Multi-agent orchestration for Claude Code
51
51
 
@@ -74,7 +74,7 @@ Commands:
74
74
  worktree <sub> Manage worktrees (list/clean)
75
75
  log <event> Log a hook event
76
76
  logs [options] Query NDJSON logs across agents
77
- watch Start watchdog daemon
77
+ watchman <sub> Unified daemon — health + mail + beacon (start/stop/status)
78
78
  feed [options] Unified real-time event stream across all agents
79
79
  trace <target> Chronological event timeline for agent/bead
80
80
  errors [options] Aggregated error view across agents
@@ -111,13 +111,13 @@ const COMMANDS = [
111
111
  "hooks",
112
112
  "monitor",
113
113
  "mail",
114
+ "watchman",
114
115
  "merge",
115
116
  "nudge",
116
117
  "group",
117
118
  "worktree",
118
119
  "log",
119
120
  "logs",
120
- "watch",
121
121
  "trace",
122
122
  "feed",
123
123
  "errors",
@@ -251,6 +251,9 @@ async function main(): Promise<void> {
251
251
  case "mail":
252
252
  await mailCommand(commandArgs);
253
253
  break;
254
+ case "watchman":
255
+ await watchmanCommand(commandArgs);
256
+ break;
254
257
  case "merge":
255
258
  await mergeCommand(commandArgs);
256
259
  break;
@@ -269,9 +272,6 @@ async function main(): Promise<void> {
269
272
  case "logs":
270
273
  await logsCommand(commandArgs);
271
274
  break;
272
- case "watch":
273
- await watchCommand(commandArgs);
274
- break;
275
275
  case "trace":
276
276
  await traceCommand(commandArgs);
277
277
  break;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Pending nudge marker utilities for inter-agent mail delivery.
3
+ *
4
+ * Instead of sending tmux keys (which corrupt tool I/O), auto-nudge writes
5
+ * a JSON marker file per agent. The `mail check --inject` flow reads and
6
+ * clears these markers, prepending a priority banner to the injected output.
7
+ *
8
+ * Extracted from src/commands/mail.ts for shared use by the watchman daemon.
9
+ */
10
+
11
+ import { access, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+
14
+ /** Shape of a pending nudge marker file. */
15
+ export interface PendingNudge {
16
+ from: string;
17
+ reason: string;
18
+ subject: string;
19
+ messageId: string;
20
+ createdAt: string;
21
+ }
22
+
23
+ /** Directory where pending nudge markers are stored. */
24
+ export function pendingNudgeDir(cwd: string): string {
25
+ return join(cwd, ".legio", "pending-nudges");
26
+ }
27
+
28
+ /**
29
+ * Write a pending nudge marker for an agent.
30
+ *
31
+ * Creates `.legio/pending-nudges/{agent}.json` so that the next
32
+ * `mail check --inject` call surfaces a priority banner for this message.
33
+ * Overwrites any existing marker (only the latest nudge matters).
34
+ */
35
+ export async function writePendingNudge(
36
+ cwd: string,
37
+ agentName: string,
38
+ nudge: Omit<PendingNudge, "createdAt">,
39
+ ): Promise<void> {
40
+ const dir = pendingNudgeDir(cwd);
41
+ await mkdir(dir, { recursive: true });
42
+
43
+ const marker: PendingNudge = {
44
+ ...nudge,
45
+ createdAt: new Date().toISOString(),
46
+ };
47
+ const filePath = join(dir, `${agentName}.json`);
48
+ const tmpPath = `${filePath}.tmp`;
49
+ await writeFile(tmpPath, `${JSON.stringify(marker, null, "\t")}\n`);
50
+ await rename(tmpPath, filePath);
51
+ }
52
+
53
+ /**
54
+ * Read and clear pending nudge markers for an agent.
55
+ *
56
+ * Returns the pending nudge (if any) and removes the marker file.
57
+ * Called by `mail check --inject` to prepend a priority banner.
58
+ */
59
+ export async function readAndClearPendingNudge(
60
+ cwd: string,
61
+ agentName: string,
62
+ ): Promise<PendingNudge | null> {
63
+ const filePath = join(pendingNudgeDir(cwd), `${agentName}.json`);
64
+ try {
65
+ await access(filePath);
66
+ } catch {
67
+ return null;
68
+ }
69
+ try {
70
+ const text = await readFile(filePath, "utf-8");
71
+ const nudge = JSON.parse(text) as PendingNudge;
72
+ await unlink(filePath);
73
+ return nudge;
74
+ } catch {
75
+ // Corrupt or race condition — clear it and move on
76
+ try {
77
+ await unlink(filePath);
78
+ } catch {
79
+ // Already gone
80
+ }
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Check if a pending nudge marker exists for an agent.
87
+ */
88
+ export async function pendingNudgeExists(cwd: string, agentName: string): Promise<boolean> {
89
+ const filePath = join(pendingNudgeDir(cwd), `${agentName}.json`);
90
+ try {
91
+ await access(filePath);
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Check if an agent is idle (not actively executing a tool).
100
+ *
101
+ * An agent is considered idle when `.legio/agent-busy/{agentName}` does NOT exist
102
+ * or when the marker is stale (older than 5 minutes, indicating a crashed agent).
103
+ * The busy marker contains an ISO timestamp written by hooks during active tool execution.
104
+ * Idle agents can receive a direct tmux nudge; busy agents only get the pending marker.
105
+ */
106
+ export async function isAgentIdle(cwd: string, agentName: string): Promise<boolean> {
107
+ const busyPath = join(cwd, ".legio", "agent-busy", agentName);
108
+ try {
109
+ const timestamp = await readFile(busyPath, "utf-8");
110
+ const age = Date.now() - new Date(timestamp.trim()).getTime();
111
+ if (age > 5 * 60 * 1000) {
112
+ // Stale marker from crashed agent — clean up
113
+ await unlink(busyPath).catch(() => {});
114
+ return true;
115
+ }
116
+ return false; // busy marker present and fresh — agent is actively working
117
+ } catch {
118
+ return true; // no busy marker — agent is idle
119
+ }
120
+ }
@@ -658,6 +658,95 @@ describe("createMailStore", () => {
658
658
  });
659
659
  });
660
660
 
661
+ describe("getAgentsWithUnread", () => {
662
+ test("returns empty array when no messages", () => {
663
+ const agents = store.getAgentsWithUnread();
664
+ expect(agents).toHaveLength(0);
665
+ });
666
+
667
+ test("returns empty array when all messages are read", () => {
668
+ const msg = store.insert({
669
+ id: "",
670
+ from: "agent-a",
671
+ to: "orchestrator",
672
+ subject: "test",
673
+ body: "body",
674
+ type: "status",
675
+ priority: "normal",
676
+ threadId: null,
677
+ });
678
+ store.markRead(msg.id);
679
+
680
+ const agents = store.getAgentsWithUnread();
681
+ expect(agents).toHaveLength(0);
682
+ });
683
+
684
+ test("returns distinct agent names with unread mail", () => {
685
+ store.insert({
686
+ id: "",
687
+ from: "agent-a",
688
+ to: "orchestrator",
689
+ subject: "msg1",
690
+ body: "body",
691
+ type: "status",
692
+ priority: "normal",
693
+ threadId: null,
694
+ });
695
+ store.insert({
696
+ id: "",
697
+ from: "agent-b",
698
+ to: "orchestrator",
699
+ subject: "msg2",
700
+ body: "body",
701
+ type: "status",
702
+ priority: "normal",
703
+ threadId: null,
704
+ });
705
+ store.insert({
706
+ id: "",
707
+ from: "agent-a",
708
+ to: "builder-1",
709
+ subject: "msg3",
710
+ body: "body",
711
+ type: "status",
712
+ priority: "normal",
713
+ threadId: null,
714
+ });
715
+
716
+ const agents = store.getAgentsWithUnread();
717
+ expect(agents).toHaveLength(2);
718
+ expect(agents.sort()).toEqual(["builder-1", "orchestrator"]);
719
+ });
720
+
721
+ test("does not include agents whose mail is all read", () => {
722
+ const msg1 = store.insert({
723
+ id: "",
724
+ from: "agent-a",
725
+ to: "orchestrator",
726
+ subject: "msg1",
727
+ body: "body",
728
+ type: "status",
729
+ priority: "normal",
730
+ threadId: null,
731
+ });
732
+ store.insert({
733
+ id: "",
734
+ from: "agent-a",
735
+ to: "builder-1",
736
+ subject: "msg2",
737
+ body: "body",
738
+ type: "status",
739
+ priority: "normal",
740
+ threadId: null,
741
+ });
742
+ store.markRead(msg1.id);
743
+
744
+ const agents = store.getAgentsWithUnread();
745
+ expect(agents).toHaveLength(1);
746
+ expect(agents[0]).toBe("builder-1");
747
+ });
748
+ });
749
+
661
750
  describe("audience column", () => {
662
751
  test("defaults audience to agent when not provided", () => {
663
752
  const msg = store.insert({
package/src/mail/store.ts CHANGED
@@ -28,6 +28,8 @@ export interface MailStore {
28
28
  getById(id: string): MailMessage | null;
29
29
  getByThread(threadId: string): MailMessage[];
30
30
  markRead(id: string): void;
31
+ /** Get distinct agent names that have unread messages. */
32
+ getAgentsWithUnread(): string[];
31
33
  /** Delete messages matching the given criteria. Returns the number of messages deleted. */
32
34
  purge(options: { all?: boolean; olderThanMs?: number; agent?: string }): number;
33
35
  close(): void;
@@ -243,6 +245,10 @@ export function createMailStore(dbPath: string): MailStore {
243
245
  UPDATE messages SET read = 1 WHERE id = $id
244
246
  `);
245
247
 
248
+ const getAgentsWithUnreadStmt = db.prepare(`
249
+ SELECT DISTINCT to_agent FROM messages WHERE read = 0
250
+ `);
251
+
246
252
  // Dynamic filter queries are built at call time since the WHERE clause varies
247
253
  function buildFilterQuery(filters?: {
248
254
  from?: string;
@@ -348,6 +354,11 @@ export function createMailStore(dbPath: string): MailStore {
348
354
  markReadStmt.run({ id });
349
355
  },
350
356
 
357
+ getAgentsWithUnread(): string[] {
358
+ const rows = getAgentsWithUnreadStmt.all() as Array<{ to_agent: string }>;
359
+ return rows.map((r) => r.to_agent);
360
+ },
361
+
351
362
  purge(options: { all?: boolean; olderThanMs?: number; agent?: string }): number {
352
363
  // Count matching rows before deletion so we can report accurate numbers
353
364
  if (options.all) {