@os-eco/overstory-cli 0.6.1
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.
- package/LICENSE +21 -0
- package/README.md +381 -0
- package/agents/builder.md +137 -0
- package/agents/coordinator.md +263 -0
- package/agents/lead.md +301 -0
- package/agents/merger.md +160 -0
- package/agents/monitor.md +214 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +119 -0
- package/agents/supervisor.md +423 -0
- package/package.json +47 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/hooks-deployer.test.ts +2040 -0
- package/src/agents/hooks-deployer.ts +607 -0
- package/src/agents/identity.test.ts +603 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/manifest.test.ts +746 -0
- package/src/agents/manifest.ts +354 -0
- package/src/agents/overlay.test.ts +676 -0
- package/src/agents/overlay.ts +308 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +202 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +322 -0
- package/src/commands/agents.ts +287 -0
- package/src/commands/clean.test.ts +670 -0
- package/src/commands/clean.ts +618 -0
- package/src/commands/completions.test.ts +342 -0
- package/src/commands/completions.ts +887 -0
- package/src/commands/coordinator.test.ts +1530 -0
- package/src/commands/coordinator.ts +733 -0
- package/src/commands/costs.test.ts +1119 -0
- package/src/commands/costs.ts +564 -0
- package/src/commands/dashboard.test.ts +308 -0
- package/src/commands/dashboard.ts +838 -0
- package/src/commands/doctor.test.ts +294 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/errors.test.ts +647 -0
- package/src/commands/errors.ts +248 -0
- package/src/commands/feed.test.ts +578 -0
- package/src/commands/feed.ts +361 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +511 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +253 -0
- package/src/commands/init.test.ts +347 -0
- package/src/commands/init.ts +650 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +431 -0
- package/src/commands/log.test.ts +1454 -0
- package/src/commands/log.ts +724 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +546 -0
- package/src/commands/mail.test.ts +1270 -0
- package/src/commands/mail.ts +771 -0
- package/src/commands/merge.test.ts +670 -0
- package/src/commands/merge.ts +355 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +143 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +390 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +372 -0
- package/src/commands/prime.test.ts +470 -0
- package/src/commands/prime.ts +381 -0
- package/src/commands/replay.test.ts +741 -0
- package/src/commands/replay.ts +360 -0
- package/src/commands/run.test.ts +431 -0
- package/src/commands/run.ts +351 -0
- package/src/commands/sling.test.ts +657 -0
- package/src/commands/sling.ts +661 -0
- package/src/commands/spec.test.ts +203 -0
- package/src/commands/spec.ts +168 -0
- package/src/commands/status.test.ts +430 -0
- package/src/commands/status.ts +398 -0
- package/src/commands/stop.test.ts +420 -0
- package/src/commands/stop.ts +151 -0
- package/src/commands/supervisor.test.ts +187 -0
- package/src/commands/supervisor.ts +535 -0
- package/src/commands/trace.test.ts +745 -0
- package/src/commands/trace.ts +325 -0
- package/src/commands/watch.test.ts +145 -0
- package/src/commands/watch.ts +247 -0
- package/src/commands/worktree.test.ts +786 -0
- package/src/commands/worktree.ts +311 -0
- package/src/config.test.ts +822 -0
- package/src/config.ts +829 -0
- package/src/doctor/agents.test.ts +454 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +190 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +651 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +290 -0
- package/src/doctor/databases.ts +218 -0
- package/src/doctor/dependencies.test.ts +184 -0
- package/src/doctor/dependencies.ts +175 -0
- package/src/doctor/logs.test.ts +251 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +216 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +291 -0
- package/src/doctor/structure.ts +198 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +136 -0
- package/src/doctor/version.ts +129 -0
- package/src/e2e/init-sling-lifecycle.test.ts +277 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +316 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +142 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +813 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +773 -0
- package/src/mail/client.ts +223 -0
- package/src/mail/store.test.ts +705 -0
- package/src/mail/store.ts +387 -0
- package/src/merge/queue.test.ts +359 -0
- package/src/merge/queue.ts +231 -0
- package/src/merge/resolver.test.ts +1345 -0
- package/src/merge/resolver.ts +645 -0
- package/src/metrics/store.test.ts +667 -0
- package/src/metrics/store.ts +445 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +356 -0
- package/src/metrics/transcript.ts +175 -0
- package/src/mulch/client.test.ts +671 -0
- package/src/mulch/client.ts +332 -0
- package/src/sessions/compat.test.ts +280 -0
- package/src/sessions/compat.ts +104 -0
- package/src/sessions/store.test.ts +873 -0
- package/src/sessions/store.ts +494 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +126 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +80 -0
- package/src/tracker/factory.ts +64 -0
- package/src/tracker/seeds.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/types.ts +724 -0
- package/src/watchdog/daemon.test.ts +1975 -0
- package/src/watchdog/daemon.ts +671 -0
- package/src/watchdog/health.test.ts +431 -0
- package/src/watchdog/health.ts +264 -0
- package/src/watchdog/triage.test.ts +164 -0
- package/src/watchdog/triage.ts +179 -0
- package/src/worktree/manager.test.ts +439 -0
- package/src/worktree/manager.ts +198 -0
- package/src/worktree/tmux.test.ts +1009 -0
- package/src/worktree/tmux.ts +509 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +105 -0
- package/templates/overlay.md.tmpl +81 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { sanitize, sanitizeObject } from "./sanitizer.ts";
|
|
3
|
+
|
|
4
|
+
describe("sanitize", () => {
|
|
5
|
+
test("redacts Anthropic API keys (sk-ant-*)", () => {
|
|
6
|
+
const input = "Using API key sk-ant-abc123xyz456 for requests";
|
|
7
|
+
const result = sanitize(input);
|
|
8
|
+
expect(result).toBe("Using API key [REDACTED] for requests");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("redacts GitHub personal access tokens (github_pat_*)", () => {
|
|
12
|
+
const input = "Token: github_pat_11ABCDEFGHIJKLMNOP";
|
|
13
|
+
const result = sanitize(input);
|
|
14
|
+
expect(result).toBe("Token: [REDACTED]");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("redacts Bearer tokens", () => {
|
|
18
|
+
const input = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
|
|
19
|
+
const result = sanitize(input);
|
|
20
|
+
expect(result).toBe("Authorization: [REDACTED]");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("redacts GitHub classic tokens (ghp_*)", () => {
|
|
24
|
+
const input = "Token ghp_1234567890abcdefghijklmnopqrstuvwxyz found in env";
|
|
25
|
+
const result = sanitize(input);
|
|
26
|
+
expect(result).toBe("Token [REDACTED] found in env");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("redacts ANTHROPIC_API_KEY environment variable", () => {
|
|
30
|
+
const input = "export ANTHROPIC_API_KEY=sk-ant-secret123";
|
|
31
|
+
const result = sanitize(input);
|
|
32
|
+
expect(result).toBe("export [REDACTED]");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("redacts multiple secrets in the same string", () => {
|
|
36
|
+
const input = "Config: ANTHROPIC_API_KEY=sk-ant-key1 and github_pat_token2 and Bearer abc123";
|
|
37
|
+
const result = sanitize(input);
|
|
38
|
+
expect(result).toBe("Config: [REDACTED] and [REDACTED] and [REDACTED]");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("preserves non-secret text", () => {
|
|
42
|
+
const input = "This is a normal message with no secrets";
|
|
43
|
+
const result = sanitize(input);
|
|
44
|
+
expect(result).toBe(input);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("handles empty string", () => {
|
|
48
|
+
const result = sanitize("");
|
|
49
|
+
expect(result).toBe("");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("redacts secrets at the beginning of the string", () => {
|
|
53
|
+
const input = "sk-ant-secret123 is the API key";
|
|
54
|
+
const result = sanitize(input);
|
|
55
|
+
expect(result).toBe("[REDACTED] is the API key");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("redacts secrets at the end of the string", () => {
|
|
59
|
+
const input = "The API key is sk-ant-secret123";
|
|
60
|
+
const result = sanitize(input);
|
|
61
|
+
expect(result).toBe("The API key is [REDACTED]");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("sanitizeObject", () => {
|
|
66
|
+
test("sanitizes string values in a flat object", () => {
|
|
67
|
+
const input = {
|
|
68
|
+
apiKey: "sk-ant-secret123",
|
|
69
|
+
username: "alice",
|
|
70
|
+
token: "github_pat_abcdef",
|
|
71
|
+
};
|
|
72
|
+
const result = sanitizeObject(input);
|
|
73
|
+
|
|
74
|
+
expect(result.apiKey).toBe("[REDACTED]");
|
|
75
|
+
expect(result.username).toBe("alice");
|
|
76
|
+
expect(result.token).toBe("[REDACTED]");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("sanitizes nested objects", () => {
|
|
80
|
+
const input = {
|
|
81
|
+
config: {
|
|
82
|
+
auth: {
|
|
83
|
+
key: "sk-ant-secret123",
|
|
84
|
+
user: "bob",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
const result = sanitizeObject(input);
|
|
89
|
+
|
|
90
|
+
const config = result.config as Record<string, unknown>;
|
|
91
|
+
const auth = config.auth as Record<string, unknown>;
|
|
92
|
+
expect(auth.key).toBe("[REDACTED]");
|
|
93
|
+
expect(auth.user).toBe("bob");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("sanitizes arrays of strings", () => {
|
|
97
|
+
const input = {
|
|
98
|
+
tokens: ["sk-ant-secret1", "safe-value", "github_pat_secret2"],
|
|
99
|
+
};
|
|
100
|
+
const result = sanitizeObject(input);
|
|
101
|
+
|
|
102
|
+
const tokens = result.tokens as string[];
|
|
103
|
+
expect(tokens[0]).toBe("[REDACTED]");
|
|
104
|
+
expect(tokens[1]).toBe("safe-value");
|
|
105
|
+
expect(tokens[2]).toBe("[REDACTED]");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("sanitizes arrays of objects", () => {
|
|
109
|
+
const input = {
|
|
110
|
+
credentials: [
|
|
111
|
+
{ key: "sk-ant-secret1", name: "alice" },
|
|
112
|
+
{ key: "safe-key", name: "bob" },
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
const result = sanitizeObject(input);
|
|
116
|
+
|
|
117
|
+
const credentials = result.credentials as Array<Record<string, unknown>>;
|
|
118
|
+
expect(credentials[0]?.key).toBe("[REDACTED]");
|
|
119
|
+
expect(credentials[0]?.name).toBe("alice");
|
|
120
|
+
expect(credentials[1]?.key).toBe("safe-key");
|
|
121
|
+
expect(credentials[1]?.name).toBe("bob");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("preserves non-string primitives", () => {
|
|
125
|
+
const input = {
|
|
126
|
+
count: 42,
|
|
127
|
+
enabled: true,
|
|
128
|
+
ratio: 3.14,
|
|
129
|
+
missing: null,
|
|
130
|
+
};
|
|
131
|
+
const result = sanitizeObject(input);
|
|
132
|
+
|
|
133
|
+
expect(result.count).toBe(42);
|
|
134
|
+
expect(result.enabled).toBe(true);
|
|
135
|
+
expect(result.ratio).toBe(3.14);
|
|
136
|
+
expect(result.missing).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("handles deeply nested structures", () => {
|
|
140
|
+
const input = {
|
|
141
|
+
level1: {
|
|
142
|
+
level2: {
|
|
143
|
+
level3: {
|
|
144
|
+
secret: "sk-ant-deep-secret",
|
|
145
|
+
safe: "value",
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
const result = sanitizeObject(input);
|
|
151
|
+
|
|
152
|
+
const level1 = result.level1 as Record<string, unknown>;
|
|
153
|
+
const level2 = level1.level2 as Record<string, unknown>;
|
|
154
|
+
const level3 = level2.level3 as Record<string, unknown>;
|
|
155
|
+
expect(level3.secret).toBe("[REDACTED]");
|
|
156
|
+
expect(level3.safe).toBe("value");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("handles mixed arrays and objects", () => {
|
|
160
|
+
const input = {
|
|
161
|
+
items: [{ key: "sk-ant-secret1" }, ["github_pat_secret2", "safe-value"], "Bearer token123"],
|
|
162
|
+
};
|
|
163
|
+
const result = sanitizeObject(input);
|
|
164
|
+
|
|
165
|
+
const items = result.items as Array<unknown>;
|
|
166
|
+
expect((items[0] as Record<string, unknown>).key).toBe("[REDACTED]");
|
|
167
|
+
expect((items[1] as string[])[0]).toBe("[REDACTED]");
|
|
168
|
+
expect((items[1] as string[])[1]).toBe("safe-value");
|
|
169
|
+
expect(items[2]).toBe("[REDACTED]");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("returns a new object (does not mutate input)", () => {
|
|
173
|
+
const input = {
|
|
174
|
+
key: "sk-ant-secret123",
|
|
175
|
+
value: "safe",
|
|
176
|
+
};
|
|
177
|
+
const result = sanitizeObject(input);
|
|
178
|
+
|
|
179
|
+
// Original should be unchanged
|
|
180
|
+
expect(input.key).toBe("sk-ant-secret123");
|
|
181
|
+
// Result should be redacted
|
|
182
|
+
expect(result.key).toBe("[REDACTED]");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("handles empty object", () => {
|
|
186
|
+
const input = {};
|
|
187
|
+
const result = sanitizeObject(input);
|
|
188
|
+
expect(result).toEqual({});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret redaction for log output.
|
|
3
|
+
*
|
|
4
|
+
* Scans strings and objects for known secret patterns (API keys, tokens, etc.)
|
|
5
|
+
* and replaces them with "[REDACTED]" to prevent accidental credential leakage.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const REDACT_PATTERNS: RegExp[] = [
|
|
9
|
+
/sk-ant-[a-zA-Z0-9_-]+/g,
|
|
10
|
+
/github_pat_[a-zA-Z0-9_]+/g,
|
|
11
|
+
/Bearer\s+[a-zA-Z0-9._-]+/g,
|
|
12
|
+
/ghp_[a-zA-Z0-9]+/g,
|
|
13
|
+
/ANTHROPIC_API_KEY=[^\s]+/g,
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const REDACTED = "[REDACTED]";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Replace all known secret patterns in a string with "[REDACTED]".
|
|
20
|
+
*/
|
|
21
|
+
export function sanitize(input: string): string {
|
|
22
|
+
let result = input;
|
|
23
|
+
for (const pattern of REDACT_PATTERNS) {
|
|
24
|
+
// Reset lastIndex since we reuse global regexps
|
|
25
|
+
pattern.lastIndex = 0;
|
|
26
|
+
result = result.replace(pattern, REDACTED);
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Deep-clone an object and sanitize all string values within it.
|
|
33
|
+
* Handles nested objects and arrays. Non-string primitives are passed through.
|
|
34
|
+
*/
|
|
35
|
+
export function sanitizeObject(obj: Record<string, unknown>): Record<string, unknown> {
|
|
36
|
+
return sanitizeValue(obj) as Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sanitizeValue(value: unknown): unknown {
|
|
40
|
+
if (typeof value === "string") {
|
|
41
|
+
return sanitize(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (Array.isArray(value)) {
|
|
45
|
+
return value.map(sanitizeValue);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (value !== null && typeof value === "object") {
|
|
49
|
+
const result: Record<string, unknown> = {};
|
|
50
|
+
for (const key of Object.keys(value as Record<string, unknown>)) {
|
|
51
|
+
result[key] = sanitizeValue((value as Record<string, unknown>)[key]);
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { AgentSession } from "../types.ts";
|
|
3
|
+
import { isGroupAddress, resolveGroupAddress } from "./broadcast.ts";
|
|
4
|
+
|
|
5
|
+
describe("isGroupAddress", () => {
|
|
6
|
+
test("returns true for addresses starting with @", () => {
|
|
7
|
+
expect(isGroupAddress("@all")).toBe(true);
|
|
8
|
+
expect(isGroupAddress("@builders")).toBe(true);
|
|
9
|
+
expect(isGroupAddress("@scouts")).toBe(true);
|
|
10
|
+
expect(isGroupAddress("@Builder")).toBe(true); // case-insensitive input allowed
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("returns false for regular agent names", () => {
|
|
14
|
+
expect(isGroupAddress("orchestrator")).toBe(false);
|
|
15
|
+
expect(isGroupAddress("my-builder-agent")).toBe(false);
|
|
16
|
+
expect(isGroupAddress("scout-001")).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("returns false for empty string", () => {
|
|
20
|
+
expect(isGroupAddress("")).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("resolveGroupAddress", () => {
|
|
25
|
+
// Helper to create minimal AgentSession fixtures
|
|
26
|
+
function createSession(agentName: string, capability: string): AgentSession {
|
|
27
|
+
return {
|
|
28
|
+
id: `session-${agentName}`,
|
|
29
|
+
agentName,
|
|
30
|
+
capability,
|
|
31
|
+
worktreePath: `/worktrees/${agentName}`,
|
|
32
|
+
branchName: `branch-${agentName}`,
|
|
33
|
+
beadId: "bead-001",
|
|
34
|
+
tmuxSession: `overstory-test-${agentName}`,
|
|
35
|
+
state: "working",
|
|
36
|
+
pid: 12345,
|
|
37
|
+
parentAgent: null,
|
|
38
|
+
depth: 0,
|
|
39
|
+
runId: "run-001",
|
|
40
|
+
startedAt: "2024-01-01T00:00:00Z",
|
|
41
|
+
lastActivity: "2024-01-01T00:01:00Z",
|
|
42
|
+
escalationLevel: 0,
|
|
43
|
+
stalledSince: null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const activeSessions: AgentSession[] = [
|
|
48
|
+
createSession("orchestrator", "coordinator"),
|
|
49
|
+
createSession("builder-1", "builder"),
|
|
50
|
+
createSession("builder-2", "builder"),
|
|
51
|
+
createSession("scout-1", "scout"),
|
|
52
|
+
createSession("reviewer-1", "reviewer"),
|
|
53
|
+
createSession("lead-1", "lead"),
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
describe("@all group", () => {
|
|
57
|
+
test("resolves to all active agents except sender", () => {
|
|
58
|
+
const recipients = resolveGroupAddress("@all", activeSessions, "orchestrator");
|
|
59
|
+
expect(recipients).toEqual(["builder-1", "builder-2", "scout-1", "reviewer-1", "lead-1"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("excludes sender from recipients", () => {
|
|
63
|
+
const recipients = resolveGroupAddress("@all", activeSessions, "builder-1");
|
|
64
|
+
expect(recipients).toContain("orchestrator");
|
|
65
|
+
expect(recipients).toContain("builder-2");
|
|
66
|
+
expect(recipients).not.toContain("builder-1"); // sender excluded
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("throws when group resolves to zero recipients", () => {
|
|
70
|
+
const singleSession = [createSession("solo", "builder")];
|
|
71
|
+
expect(() => resolveGroupAddress("@all", singleSession, "solo")).toThrow(
|
|
72
|
+
"resolved to zero recipients",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("is case-insensitive", () => {
|
|
77
|
+
const recipients = resolveGroupAddress("@ALL", activeSessions, "orchestrator");
|
|
78
|
+
expect(recipients.length).toBe(5);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("capability groups", () => {
|
|
83
|
+
test("resolves @builders to all builder agents", () => {
|
|
84
|
+
const recipients = resolveGroupAddress("@builders", activeSessions, "orchestrator");
|
|
85
|
+
expect(recipients).toEqual(["builder-1", "builder-2"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("resolves @scouts to all scout agents", () => {
|
|
89
|
+
const recipients = resolveGroupAddress("@scouts", activeSessions, "orchestrator");
|
|
90
|
+
expect(recipients).toEqual(["scout-1"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("resolves @reviewers to all reviewer agents", () => {
|
|
94
|
+
const recipients = resolveGroupAddress("@reviewers", activeSessions, "orchestrator");
|
|
95
|
+
expect(recipients).toEqual(["reviewer-1"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("resolves @leads to all lead agents", () => {
|
|
99
|
+
const recipients = resolveGroupAddress("@leads", activeSessions, "orchestrator");
|
|
100
|
+
expect(recipients).toEqual(["lead-1"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("excludes sender from capability group", () => {
|
|
104
|
+
const recipients = resolveGroupAddress("@builders", activeSessions, "builder-1");
|
|
105
|
+
expect(recipients).toEqual(["builder-2"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("throws when capability group has no matching agents", () => {
|
|
109
|
+
const noMergers = activeSessions.filter((s) => s.capability !== "merger");
|
|
110
|
+
expect(() => resolveGroupAddress("@mergers", noMergers, "orchestrator")).toThrow(
|
|
111
|
+
"resolved to zero recipients",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("throws when all matching agents are the sender", () => {
|
|
116
|
+
const singleBuilder = [createSession("solo-builder", "builder")];
|
|
117
|
+
expect(() => resolveGroupAddress("@builders", singleBuilder, "solo-builder")).toThrow(
|
|
118
|
+
"resolved to zero recipients",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("singular aliases", () => {
|
|
124
|
+
test("@builder resolves same as @builders", () => {
|
|
125
|
+
const singular = resolveGroupAddress("@builder", activeSessions, "orchestrator");
|
|
126
|
+
const plural = resolveGroupAddress("@builders", activeSessions, "orchestrator");
|
|
127
|
+
expect(singular).toEqual(plural);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("@scout resolves same as @scouts", () => {
|
|
131
|
+
const singular = resolveGroupAddress("@scout", activeSessions, "orchestrator");
|
|
132
|
+
const plural = resolveGroupAddress("@scouts", activeSessions, "orchestrator");
|
|
133
|
+
expect(singular).toEqual(plural);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("@reviewer resolves same as @reviewers", () => {
|
|
137
|
+
const singular = resolveGroupAddress("@reviewer", activeSessions, "orchestrator");
|
|
138
|
+
const plural = resolveGroupAddress("@reviewers", activeSessions, "orchestrator");
|
|
139
|
+
expect(singular).toEqual(plural);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("@lead resolves same as @leads", () => {
|
|
143
|
+
const singular = resolveGroupAddress("@lead", activeSessions, "orchestrator");
|
|
144
|
+
const plural = resolveGroupAddress("@leads", activeSessions, "orchestrator");
|
|
145
|
+
expect(singular).toEqual(plural);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("@merger resolves same as @mergers", () => {
|
|
149
|
+
const withMerger = [...activeSessions, createSession("merger-1", "merger")];
|
|
150
|
+
const singular = resolveGroupAddress("@merger", withMerger, "orchestrator");
|
|
151
|
+
const plural = resolveGroupAddress("@mergers", withMerger, "orchestrator");
|
|
152
|
+
expect(singular).toEqual(plural);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("@supervisor resolves same as @supervisors", () => {
|
|
156
|
+
const withSupervisor = [...activeSessions, createSession("supervisor-1", "supervisor")];
|
|
157
|
+
const singular = resolveGroupAddress("@supervisor", withSupervisor, "orchestrator");
|
|
158
|
+
const plural = resolveGroupAddress("@supervisors", withSupervisor, "orchestrator");
|
|
159
|
+
expect(singular).toEqual(plural);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("@coordinator resolves same as @coordinators", () => {
|
|
163
|
+
const singular = resolveGroupAddress("@coordinator", activeSessions, "builder-1");
|
|
164
|
+
const plural = resolveGroupAddress("@coordinators", activeSessions, "builder-1");
|
|
165
|
+
expect(singular).toEqual(plural);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("@monitor resolves same as @monitors", () => {
|
|
169
|
+
const withMonitor = [...activeSessions, createSession("monitor-1", "monitor")];
|
|
170
|
+
const singular = resolveGroupAddress("@monitor", withMonitor, "orchestrator");
|
|
171
|
+
const plural = resolveGroupAddress("@monitors", withMonitor, "orchestrator");
|
|
172
|
+
expect(singular).toEqual(plural);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("unknown groups", () => {
|
|
177
|
+
test("throws for unknown group address", () => {
|
|
178
|
+
expect(() => resolveGroupAddress("@unknown", activeSessions, "orchestrator")).toThrow(
|
|
179
|
+
"Unknown group address",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("error message lists valid groups", () => {
|
|
184
|
+
expect(() => resolveGroupAddress("@invalid", activeSessions, "orchestrator")).toThrow("@all");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("edge cases", () => {
|
|
189
|
+
test("handles empty active sessions list", () => {
|
|
190
|
+
expect(() => resolveGroupAddress("@all", [], "orchestrator")).toThrow(
|
|
191
|
+
"resolved to zero recipients",
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("handles sessions with mixed case capability names", () => {
|
|
196
|
+
const mixedCase = [createSession("builder-1", "Builder")];
|
|
197
|
+
// Capability groups match exact string — "Builder" !== "builder"
|
|
198
|
+
expect(() => resolveGroupAddress("@builders", mixedCase, "orchestrator")).toThrow(
|
|
199
|
+
"resolved to zero recipients",
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group address resolution for broadcast messaging.
|
|
3
|
+
*
|
|
4
|
+
* Provides pure logic for resolving group addresses (e.g., @all, @builders)
|
|
5
|
+
* into lists of individual agent names. No I/O — takes active sessions as
|
|
6
|
+
* input and returns agent names as output.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AgentSession } from "../types.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Check if a recipient address is a group address.
|
|
13
|
+
* Group addresses start with '@'.
|
|
14
|
+
*/
|
|
15
|
+
export function isGroupAddress(recipient: string): boolean {
|
|
16
|
+
return recipient.startsWith("@");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Capability group prefixes. Matches singular and plural forms.
|
|
21
|
+
* E.g., @builder and @builders both resolve to agents with capability "builder".
|
|
22
|
+
*/
|
|
23
|
+
const CAPABILITY_GROUPS: Record<string, string> = {
|
|
24
|
+
"@builder": "builder",
|
|
25
|
+
"@builders": "builder",
|
|
26
|
+
"@scout": "scout",
|
|
27
|
+
"@scouts": "scout",
|
|
28
|
+
"@reviewer": "reviewer",
|
|
29
|
+
"@reviewers": "reviewer",
|
|
30
|
+
"@lead": "lead",
|
|
31
|
+
"@leads": "lead",
|
|
32
|
+
"@merger": "merger",
|
|
33
|
+
"@mergers": "merger",
|
|
34
|
+
"@supervisor": "supervisor",
|
|
35
|
+
"@supervisors": "supervisor",
|
|
36
|
+
"@coordinator": "coordinator",
|
|
37
|
+
"@coordinators": "coordinator",
|
|
38
|
+
"@monitor": "monitor",
|
|
39
|
+
"@monitors": "monitor",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a group address to a list of agent names.
|
|
44
|
+
*
|
|
45
|
+
* @param groupAddress - The group address to resolve (e.g., "@all", "@builders")
|
|
46
|
+
* @param activeSessions - List of active agent sessions
|
|
47
|
+
* @param senderName - Name of the sender (excluded from recipients)
|
|
48
|
+
* @returns Array of agent names that match the group
|
|
49
|
+
* @throws Error if the group address is unknown or resolves to zero recipients
|
|
50
|
+
*/
|
|
51
|
+
export function resolveGroupAddress(
|
|
52
|
+
groupAddress: string,
|
|
53
|
+
activeSessions: AgentSession[],
|
|
54
|
+
senderName: string,
|
|
55
|
+
): string[] {
|
|
56
|
+
const normalized = groupAddress.toLowerCase();
|
|
57
|
+
|
|
58
|
+
// Handle @all — all active agents except sender
|
|
59
|
+
if (normalized === "@all") {
|
|
60
|
+
const recipients = activeSessions.map((s) => s.agentName).filter((name) => name !== senderName);
|
|
61
|
+
|
|
62
|
+
if (recipients.length === 0) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Group address "${groupAddress}" resolved to zero recipients (sender excluded)`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return recipients;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Handle capability groups
|
|
72
|
+
const capability = CAPABILITY_GROUPS[normalized];
|
|
73
|
+
if (capability !== undefined) {
|
|
74
|
+
const recipients = activeSessions
|
|
75
|
+
.filter((s) => s.capability === capability)
|
|
76
|
+
.map((s) => s.agentName)
|
|
77
|
+
.filter((name) => name !== senderName);
|
|
78
|
+
|
|
79
|
+
if (recipients.length === 0) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Group address "${groupAddress}" resolved to zero recipients (no active ${capability} agents)`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return recipients;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Unknown group
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Unknown group address: "${groupAddress}". Valid groups: @all, ${Object.keys(CAPABILITY_GROUPS).join(", ")}`,
|
|
91
|
+
);
|
|
92
|
+
}
|