@teammates/cli 0.1.0

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.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Dropdown — renders lines below the readline prompt.
3
+ *
4
+ * Hooks _refreshLine:
5
+ * 1. origRefresh runs: clears screen, writes prompt, positions cursor
6
+ * 2. We append dropdown lines with \n
7
+ * 3. We adjust prevRows so the NEXT _refreshLine's moveCursor(0, -prevRows)
8
+ * moves the cursor back up past the dropdown lines to the prompt
9
+ */
10
+ import type { Interface as ReadlineInterface } from "node:readline";
11
+ export declare class Dropdown {
12
+ private rl;
13
+ private lines;
14
+ private out;
15
+ private refreshing;
16
+ constructor(rl: ReadlineInterface);
17
+ get rendered(): number;
18
+ /** Set dropdown content. Triggers _refreshLine to display. */
19
+ render(newLines: string[]): void;
20
+ /** Clear dropdown. Next _refreshLine won't append anything. */
21
+ clear(): void;
22
+ private installHook;
23
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Dropdown — renders lines below the readline prompt.
3
+ *
4
+ * Hooks _refreshLine:
5
+ * 1. origRefresh runs: clears screen, writes prompt, positions cursor
6
+ * 2. We append dropdown lines with \n
7
+ * 3. We adjust prevRows so the NEXT _refreshLine's moveCursor(0, -prevRows)
8
+ * moves the cursor back up past the dropdown lines to the prompt
9
+ */
10
+ /** Truncate a string with ANSI codes to `max` visible characters. */
11
+ function truncateAnsi(str, max) {
12
+ let visible = 0;
13
+ let i = 0;
14
+ while (i < str.length && visible < max) {
15
+ if (str[i] === "\x1b") {
16
+ // Skip ANSI sequence
17
+ const end = str.indexOf("m", i);
18
+ if (end !== -1) {
19
+ i = end + 1;
20
+ continue;
21
+ }
22
+ }
23
+ visible++;
24
+ i++;
25
+ }
26
+ return str.slice(0, i);
27
+ }
28
+ export class Dropdown {
29
+ rl;
30
+ lines = [];
31
+ out = process.stdout;
32
+ refreshing = false; // guard against recursion
33
+ constructor(rl) {
34
+ this.rl = rl;
35
+ this.installHook();
36
+ }
37
+ get rendered() {
38
+ return this.lines.length;
39
+ }
40
+ /** Set dropdown content. Triggers _refreshLine to display. */
41
+ render(newLines) {
42
+ this.lines = newLines;
43
+ this.rl._refreshLine();
44
+ }
45
+ /** Clear dropdown. Next _refreshLine won't append anything. */
46
+ clear() {
47
+ this.lines = [];
48
+ }
49
+ installHook() {
50
+ const origRefresh = this.rl._refreshLine.bind(this.rl);
51
+ this.rl._refreshLine = () => {
52
+ // Guard: render() calls _refreshLine, which must not recurse
53
+ if (this.refreshing) {
54
+ origRefresh();
55
+ return;
56
+ }
57
+ this.refreshing = true;
58
+ // 1. Run the original: clears below, writes prompt, positions cursor
59
+ origRefresh();
60
+ // 2. Append dropdown lines below the prompt (truncated to prevent wrapping)
61
+ if (this.lines.length > 0) {
62
+ const cols = this.out.columns || 120;
63
+ let buf = "";
64
+ for (const line of this.lines) {
65
+ buf += "\n" + truncateAnsi(line, cols - 1);
66
+ }
67
+ this.out.write(buf);
68
+ // 3. Move cursor back up to the prompt line and restore column.
69
+ // Don't touch prevRows — cursor IS on the prompt line after this.
70
+ const n = this.lines.length;
71
+ this.out.write(`\x1b[${n}A`);
72
+ const promptText = this.rl._prompt ?? "";
73
+ const promptLen = promptText.replace(/\x1b\[[0-9;]*m/g, "").length;
74
+ const cursor = this.rl.cursor ?? 0;
75
+ this.out.write(`\x1b[${promptLen + cursor + 1}G`);
76
+ }
77
+ this.refreshing = false;
78
+ };
79
+ }
80
+ }
@@ -0,0 +1,7 @@
1
+ export type { TeammateConfig, DailyLog, OwnershipRules, SandboxLevel, HandoffEnvelope, TaskResult, TaskAssignment, OrchestratorEvent, } from "./types.js";
2
+ export type { AgentAdapter, RosterEntry, InstalledService } from "./adapter.js";
3
+ export { buildTeammatePrompt, formatHandoffContext } from "./adapter.js";
4
+ export { Registry } from "./registry.js";
5
+ export { Orchestrator, type OrchestratorConfig, type TeammateStatus, } from "./orchestrator.js";
6
+ export { EchoAdapter } from "./adapters/echo.js";
7
+ export { CliProxyAdapter, PRESETS, type AgentPreset, type CliProxyOptions, } from "./adapters/cli-proxy.js";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ // Public API for @teammates/cli
2
+ export { buildTeammatePrompt, formatHandoffContext } from "./adapter.js";
3
+ export { Registry } from "./registry.js";
4
+ export { Orchestrator, } from "./orchestrator.js";
5
+ export { EchoAdapter } from "./adapters/echo.js";
6
+ export { CliProxyAdapter, PRESETS, } from "./adapters/cli-proxy.js";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Onboarding flow — guides users through setting up .teammates/ when none exists.
3
+ *
4
+ * Ships with a copy of the template/ folder. Framework files (CROSS-TEAM.md,
5
+ * PROTOCOL.md, TEMPLATE.md, USER.md, .gitignore, example/) are copied into the
6
+ * target .teammates/ directory before the agent runs, so the agent only needs to
7
+ * analyze the codebase and create teammate-specific folders.
8
+ */
9
+ /**
10
+ * Copy framework files from the bundled template into the target .teammates/ dir.
11
+ * Skips files that already exist (idempotent).
12
+ * Returns the list of files that were copied.
13
+ */
14
+ export declare function copyTemplateFiles(teammatesDir: string): Promise<string[]>;
15
+ /**
16
+ * Load ONBOARDING.md from the project dir, package root, or built-in fallback.
17
+ */
18
+ export declare function getOnboardingPrompt(projectDir: string): Promise<string>;
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Onboarding flow — guides users through setting up .teammates/ when none exists.
3
+ *
4
+ * Ships with a copy of the template/ folder. Framework files (CROSS-TEAM.md,
5
+ * PROTOCOL.md, TEMPLATE.md, USER.md, .gitignore, example/) are copied into the
6
+ * target .teammates/ directory before the agent runs, so the agent only needs to
7
+ * analyze the codebase and create teammate-specific folders.
8
+ */
9
+ import { readFile, readdir, copyFile, mkdir, stat } from "node:fs/promises";
10
+ import { resolve, join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ /**
14
+ * Resolve the bundled template/ directory.
15
+ * Works from both dist/ (compiled) and src/ (dev).
16
+ */
17
+ function getTemplateDir() {
18
+ const candidates = [
19
+ resolve(__dirname, "../template"), // dist/ → cli/template
20
+ resolve(__dirname, "../../template"), // src/ → cli/template (dev)
21
+ ];
22
+ return candidates[0]; // both resolve to the same cli/template
23
+ }
24
+ /**
25
+ * Copy framework files from the bundled template into the target .teammates/ dir.
26
+ * Skips files that already exist (idempotent).
27
+ * Returns the list of files that were copied.
28
+ */
29
+ export async function copyTemplateFiles(teammatesDir) {
30
+ const templateDir = getTemplateDir();
31
+ const copied = [];
32
+ // Framework files to copy at the top level
33
+ const frameworkFiles = [
34
+ "CROSS-TEAM.md",
35
+ "PROTOCOL.md",
36
+ "TEMPLATE.md",
37
+ "USER.md",
38
+ "README.md",
39
+ ];
40
+ for (const file of frameworkFiles) {
41
+ const src = join(templateDir, file);
42
+ const dest = join(teammatesDir, file);
43
+ try {
44
+ await stat(dest);
45
+ // Already exists, skip
46
+ }
47
+ catch {
48
+ try {
49
+ await copyFile(src, dest);
50
+ copied.push(file);
51
+ }
52
+ catch { /* template file missing, skip */ }
53
+ }
54
+ }
55
+ // Create .gitignore if it doesn't exist
56
+ const gitignoreDest = join(teammatesDir, ".gitignore");
57
+ try {
58
+ await stat(gitignoreDest);
59
+ }
60
+ catch {
61
+ const gitignoreContent = "USER.md\n.index/\n";
62
+ const { writeFile } = await import("node:fs/promises");
63
+ await writeFile(gitignoreDest, gitignoreContent, "utf-8");
64
+ copied.push(".gitignore");
65
+ }
66
+ // Copy example/ directory if it doesn't exist
67
+ const exampleDir = join(teammatesDir, "example");
68
+ try {
69
+ await stat(exampleDir);
70
+ }
71
+ catch {
72
+ const templateExampleDir = join(templateDir, "example");
73
+ try {
74
+ await mkdir(exampleDir, { recursive: true });
75
+ const exampleFiles = await readdir(templateExampleDir);
76
+ for (const file of exampleFiles) {
77
+ await copyFile(join(templateExampleDir, file), join(exampleDir, file));
78
+ copied.push(`example/${file}`);
79
+ }
80
+ }
81
+ catch { /* template example dir missing, skip */ }
82
+ }
83
+ return copied;
84
+ }
85
+ /**
86
+ * Load ONBOARDING.md from the project dir, package root, or built-in fallback.
87
+ */
88
+ export async function getOnboardingPrompt(projectDir) {
89
+ const candidates = [
90
+ join(projectDir, "ONBOARDING.md"), // user's project
91
+ resolve(__dirname, "../../ONBOARDING.md"), // monorepo: cli/dist/ → root
92
+ resolve(__dirname, "../../../ONBOARDING.md"), // extra nesting fallback
93
+ ];
94
+ for (const path of candidates) {
95
+ try {
96
+ const content = await readFile(path, "utf-8");
97
+ if (content.includes("## Step 1")) {
98
+ return wrapPrompt(content, projectDir);
99
+ }
100
+ }
101
+ catch { /* not found, try next */ }
102
+ }
103
+ return wrapPrompt(BUILTIN_ONBOARDING, projectDir);
104
+ }
105
+ function wrapPrompt(onboardingContent, projectDir) {
106
+ return `You are setting up the teammates framework for a project.
107
+
108
+ **Target project directory:** ${projectDir}
109
+
110
+ **Framework files have already been copied** into \`${projectDir}/.teammates/\` from the template. The following files are already in place:
111
+ - CROSS-TEAM.md — fill in the Ownership Scopes table as you create teammates
112
+ - PROTOCOL.md — team protocol (ready to use)
113
+ - TEMPLATE.md — reference for creating teammate SOUL.md and MEMORIES.md files
114
+ - USER.md — user profile (gitignored, user fills in later)
115
+ - README.md — update with project-specific roster and info
116
+ - .gitignore — configured for USER.md and .index/
117
+ - example/ — example SOUL.md and MEMORIES.md for reference
118
+
119
+ **Your job is to:**
120
+ 1. Analyze the codebase (Step 1)
121
+ 2. Design the team roster (Step 2)
122
+ 3. Create teammate folders with SOUL.md and MEMORIES.md (Step 3) — use TEMPLATE.md for the structure
123
+ 4. Update README.md and CROSS-TEAM.md with the roster info (Step 3)
124
+ 5. Verify everything is in place (Step 4)
125
+
126
+ You do NOT need to create the framework files listed above — they're already there.
127
+
128
+ Follow the onboarding instructions below. Work through each step, pausing after Step 1 and Step 2 to present your analysis and proposed roster to the user for approval before proceeding.
129
+
130
+ ---
131
+
132
+ ${onboardingContent}`;
133
+ }
134
+ const BUILTIN_ONBOARDING = `# Teammates Onboarding
135
+
136
+ You are going to analyze a codebase and create a set of AI teammates — persistent personas that each own a slice of the project. Follow these steps in order.
137
+
138
+ ## Step 1: Analyze the Codebase
139
+
140
+ Read the project's entry points to understand its structure:
141
+ - README, CONTRIBUTING, or similar docs
142
+ - Package manifest (package.json, Cargo.toml, pyproject.toml, go.mod, etc.)
143
+ - Top-level directory structure
144
+ - Key configuration files
145
+
146
+ Identify:
147
+ 1. **Major domains/subsystems** — distinct areas of the codebase
148
+ 2. **Dependency flow** — which layers depend on which
149
+ 3. **Key technologies** — languages, frameworks, tools per area
150
+ 4. **File patterns** — glob patterns for each domain
151
+
152
+ **Present your analysis to the user and get confirmation before proceeding.**
153
+
154
+ ## Step 2: Design the Team
155
+
156
+ Propose a roster of teammates:
157
+ - **Aim for 3–7 teammates.** Fewer for small projects, more for monorepos.
158
+ - **Each teammate owns a distinct domain** with minimal overlap.
159
+ - **Pick short, memorable names** — one word, evocative of the domain.
160
+
161
+ For each proposed teammate, define:
162
+ - Name and one-line persona
163
+ - Primary ownership (file patterns)
164
+ - Key technologies
165
+ - Boundaries (what they do NOT own)
166
+
167
+ **Present the proposed roster to the user for approval.**
168
+
169
+ ## Step 3: Create the Directory Structure
170
+
171
+ Once approved, create teammate folders under \`.teammates/\`:
172
+
173
+ ### Teammate folders
174
+ For each teammate, create \`.teammates/<name>/\` with:
175
+
176
+ **SOUL.md** — Use the template from \`.teammates/TEMPLATE.md\`. Fill in identity, core principles, boundaries, capabilities, ownership, ethics.
177
+
178
+ **MEMORIES.md** — Start with one entry recording creation and key decisions.
179
+
180
+ **memory/** — Empty directory for daily logs.
181
+
182
+ ### Update framework files
183
+ - Update \`.teammates/README.md\` with the roster table, dependency flow, and routing guide
184
+ - Update \`.teammates/CROSS-TEAM.md\` Ownership Scopes table with one row per teammate
185
+
186
+ ## Step 4: Verify
187
+
188
+ Check:
189
+ - Every roster teammate has a folder with SOUL.md and MEMORIES.md
190
+ - Ownership globs cover the codebase without major gaps
191
+ - Boundaries reference the correct owning teammate
192
+ - CROSS-TEAM.md Ownership Scopes table has one row per teammate with correct paths
193
+ - .gitignore is in place (USER.md not committed)
194
+
195
+ ## Tips
196
+ - Small projects are fine with 2–3 teammates
197
+ - MEMORIES.md starts light — just one creation entry
198
+ - Prompt the user to fill in USER.md after setup
199
+ `;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Orchestrator — the core of @teammates/cli.
3
+ *
4
+ * Routes tasks to teammates, manages handoff chains,
5
+ * and delegates execution to the plugged-in agent adapter.
6
+ */
7
+ import type { TaskAssignment, TaskResult, HandoffEnvelope, OrchestratorEvent } from "./types.js";
8
+ import type { AgentAdapter } from "./adapter.js";
9
+ import { Registry } from "./registry.js";
10
+ export interface OrchestratorConfig {
11
+ /** Path to .teammates/ directory */
12
+ teammatesDir: string;
13
+ /** The agent adapter to use for execution */
14
+ adapter: AgentAdapter;
15
+ /** Max handoff chain depth before stopping (default: 5) */
16
+ maxHandoffDepth?: number;
17
+ /** Event listener for logging/UI */
18
+ onEvent?: (event: OrchestratorEvent) => void;
19
+ }
20
+ export interface TeammateStatus {
21
+ state: "idle" | "working" | "pending-handoff";
22
+ lastSummary?: string;
23
+ lastChangedFiles?: string[];
24
+ lastTimestamp?: Date;
25
+ pendingHandoff?: HandoffEnvelope;
26
+ }
27
+ export declare class Orchestrator {
28
+ private registry;
29
+ private adapter;
30
+ private sessions;
31
+ private statuses;
32
+ private maxHandoffDepth;
33
+ private onEvent;
34
+ /** When true, handoffs require explicit /approve */
35
+ requireApproval: boolean;
36
+ constructor(config: OrchestratorConfig);
37
+ /** Initialize: load all teammates from disk */
38
+ init(): Promise<void>;
39
+ /** Get status for a teammate */
40
+ getStatus(name: string): TeammateStatus | undefined;
41
+ /** Get all statuses */
42
+ getAllStatuses(): Map<string, TeammateStatus>;
43
+ /** Get the pending handoff if any teammate has one */
44
+ getPendingHandoff(): HandoffEnvelope | null;
45
+ /** Clear a pending handoff (on reject) */
46
+ clearPendingHandoff(teammate: string): void;
47
+ /** List available teammates */
48
+ listTeammates(): string[];
49
+ /** Get the registry for direct access */
50
+ getRegistry(): Registry;
51
+ /**
52
+ * Assign a task to a specific teammate and execute it.
53
+ * If the result contains a handoff, follows the chain automatically.
54
+ */
55
+ assign(assignment: TaskAssignment, depth?: number, visited?: Set<string>): Promise<TaskResult>;
56
+ /**
57
+ * Route a task to the best teammate based on keyword matching.
58
+ * Uses the routing guide from .teammates/README.md ownership patterns.
59
+ */
60
+ route(task: string): string | null;
61
+ /**
62
+ * Ask the agent to pick the best teammate for a task.
63
+ * Used as a fallback when keyword routing doesn't find a strong match.
64
+ */
65
+ agentRoute(task: string): Promise<string | null>;
66
+ /** Reset all teammate statuses to idle and clear sessions */
67
+ reset(): Promise<void>;
68
+ /** Destroy all sessions */
69
+ shutdown(): Promise<void>;
70
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Orchestrator — the core of @teammates/cli.
3
+ *
4
+ * Routes tasks to teammates, manages handoff chains,
5
+ * and delegates execution to the plugged-in agent adapter.
6
+ */
7
+ import { formatHandoffContext } from "./adapter.js";
8
+ import { Registry } from "./registry.js";
9
+ export class Orchestrator {
10
+ registry;
11
+ adapter;
12
+ sessions = new Map(); // teammate -> sessionId
13
+ statuses = new Map();
14
+ maxHandoffDepth;
15
+ onEvent;
16
+ /** When true, handoffs require explicit /approve */
17
+ requireApproval = true;
18
+ constructor(config) {
19
+ this.registry = new Registry(config.teammatesDir);
20
+ this.adapter = config.adapter;
21
+ this.maxHandoffDepth = config.maxHandoffDepth ?? 5;
22
+ this.onEvent = config.onEvent ?? (() => { });
23
+ }
24
+ /** Initialize: load all teammates from disk */
25
+ async init() {
26
+ await this.registry.loadAll();
27
+ for (const name of this.registry.list()) {
28
+ this.statuses.set(name, { state: "idle" });
29
+ }
30
+ }
31
+ /** Get status for a teammate */
32
+ getStatus(name) {
33
+ return this.statuses.get(name);
34
+ }
35
+ /** Get all statuses */
36
+ getAllStatuses() {
37
+ return this.statuses;
38
+ }
39
+ /** Get the pending handoff if any teammate has one */
40
+ getPendingHandoff() {
41
+ for (const [, status] of this.statuses) {
42
+ if (status.state === "pending-handoff" && status.pendingHandoff) {
43
+ return status.pendingHandoff;
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+ /** Clear a pending handoff (on reject) */
49
+ clearPendingHandoff(teammate) {
50
+ const status = this.statuses.get(teammate);
51
+ if (status && status.state === "pending-handoff") {
52
+ status.state = "idle";
53
+ status.pendingHandoff = undefined;
54
+ }
55
+ }
56
+ /** List available teammates */
57
+ listTeammates() {
58
+ return this.registry.list();
59
+ }
60
+ /** Get the registry for direct access */
61
+ getRegistry() {
62
+ return this.registry;
63
+ }
64
+ /**
65
+ * Assign a task to a specific teammate and execute it.
66
+ * If the result contains a handoff, follows the chain automatically.
67
+ */
68
+ async assign(assignment, depth = 0, visited) {
69
+ // Normalize: strip leading @ from teammate names (agents may use @mentions)
70
+ assignment.teammate = assignment.teammate.replace(/^@/, "");
71
+ if (assignment.handoff) {
72
+ assignment.handoff.to = assignment.handoff.to.replace(/^@/, "");
73
+ assignment.handoff.from = assignment.handoff.from.replace(/^@/, "");
74
+ }
75
+ const teammate = this.registry.get(assignment.teammate);
76
+ if (!teammate) {
77
+ const error = `Unknown teammate: ${assignment.teammate}`;
78
+ this.onEvent({ type: "error", teammate: assignment.teammate, error });
79
+ return {
80
+ teammate: assignment.teammate,
81
+ success: false,
82
+ summary: error,
83
+ changedFiles: [],
84
+ };
85
+ }
86
+ // ── Handoff cycle detection ──────────────────────────────────
87
+ const chain = visited ?? new Set();
88
+ if (chain.has(assignment.teammate)) {
89
+ const cycle = [...chain, assignment.teammate].join(" → ");
90
+ const error = `Handoff cycle detected: ${cycle}`;
91
+ this.onEvent({ type: "error", teammate: assignment.teammate, error });
92
+ return {
93
+ teammate: assignment.teammate,
94
+ success: false,
95
+ summary: error,
96
+ changedFiles: [],
97
+ };
98
+ }
99
+ chain.add(assignment.teammate);
100
+ this.onEvent({ type: "task_assigned", assignment });
101
+ // Update status
102
+ this.statuses.set(assignment.teammate, { state: "working" });
103
+ // Get or create session
104
+ let sessionId = this.sessions.get(assignment.teammate);
105
+ if (!sessionId) {
106
+ sessionId = await this.adapter.startSession(teammate);
107
+ this.sessions.set(assignment.teammate, sessionId);
108
+ }
109
+ // Build prompt with handoff context if present
110
+ let prompt = assignment.task;
111
+ if (assignment.handoff) {
112
+ const handoffCtx = formatHandoffContext(assignment.handoff);
113
+ prompt = `${handoffCtx}\n\n---\n\n${prompt}`;
114
+ }
115
+ if (assignment.extraContext) {
116
+ prompt = `${assignment.extraContext}\n\n---\n\n${prompt}`;
117
+ }
118
+ // Execute
119
+ const result = await this.adapter.executeTask(sessionId, teammate, prompt);
120
+ this.onEvent({ type: "task_completed", result });
121
+ // Update status with result
122
+ const newStatus = {
123
+ state: "idle",
124
+ lastSummary: result.summary,
125
+ lastChangedFiles: result.changedFiles,
126
+ lastTimestamp: new Date(),
127
+ };
128
+ // Handle handoff
129
+ if (result.handoff && depth < this.maxHandoffDepth) {
130
+ this.onEvent({ type: "handoff_initiated", envelope: result.handoff });
131
+ if (this.requireApproval) {
132
+ // Park the handoff — user must /approve
133
+ newStatus.state = "pending-handoff";
134
+ newStatus.pendingHandoff = result.handoff;
135
+ this.statuses.set(assignment.teammate, newStatus);
136
+ return result;
137
+ }
138
+ // Auto-follow handoff
139
+ this.statuses.set(assignment.teammate, newStatus);
140
+ const nextAssignment = {
141
+ teammate: result.handoff.to,
142
+ task: result.handoff.task,
143
+ handoff: result.handoff,
144
+ };
145
+ const handoffResult = await this.assign(nextAssignment, depth + 1, chain);
146
+ this.onEvent({
147
+ type: "handoff_completed",
148
+ envelope: result.handoff,
149
+ result: handoffResult,
150
+ });
151
+ return handoffResult;
152
+ }
153
+ this.statuses.set(assignment.teammate, newStatus);
154
+ return result;
155
+ }
156
+ /**
157
+ * Route a task to the best teammate based on keyword matching.
158
+ * Uses the routing guide from .teammates/README.md ownership patterns.
159
+ */
160
+ route(task) {
161
+ const taskLower = task.toLowerCase();
162
+ let bestMatch = null;
163
+ let bestScore = 0;
164
+ for (const [name, config] of this.registry.all()) {
165
+ let score = 0;
166
+ // Check ownership patterns against task text
167
+ for (const pattern of [
168
+ ...config.ownership.primary,
169
+ ...config.ownership.secondary,
170
+ ]) {
171
+ // Extract meaningful keywords from glob patterns
172
+ const keywords = pattern
173
+ .replace(/[*\/{}]/g, " ")
174
+ .split(/\s+/)
175
+ .filter((w) => w.length > 2);
176
+ for (const kw of keywords) {
177
+ if (taskLower.includes(kw.toLowerCase())) {
178
+ score += config.ownership.primary.includes(pattern) ? 2 : 1;
179
+ }
180
+ }
181
+ }
182
+ // Check role keywords
183
+ const roleWords = config.role.toLowerCase().split(/\s+/);
184
+ for (const word of roleWords) {
185
+ if (word.length > 3 && taskLower.includes(word)) {
186
+ score++;
187
+ }
188
+ }
189
+ if (score > bestScore) {
190
+ bestScore = score;
191
+ bestMatch = name;
192
+ }
193
+ }
194
+ // Require a meaningful match — weak/ambiguous scores fall through
195
+ // so the caller can default to the base coding agent
196
+ if (bestScore < 2)
197
+ return null;
198
+ return bestMatch;
199
+ }
200
+ /**
201
+ * Ask the agent to pick the best teammate for a task.
202
+ * Used as a fallback when keyword routing doesn't find a strong match.
203
+ */
204
+ async agentRoute(task) {
205
+ if (!this.adapter.routeTask)
206
+ return null;
207
+ const roster = [];
208
+ for (const [name, config] of this.registry.all()) {
209
+ roster.push({ name, role: config.role, ownership: config.ownership });
210
+ }
211
+ // Include the base agent as an option
212
+ roster.push({
213
+ name: this.adapter.name,
214
+ role: "General-purpose coding agent",
215
+ ownership: { primary: [], secondary: [] },
216
+ });
217
+ return this.adapter.routeTask(task, roster);
218
+ }
219
+ /** Reset all teammate statuses to idle and clear sessions */
220
+ async reset() {
221
+ for (const [name, sessionId] of this.sessions) {
222
+ if (this.adapter.destroySession) {
223
+ await this.adapter.destroySession(sessionId);
224
+ }
225
+ }
226
+ this.sessions.clear();
227
+ for (const name of this.registry.list()) {
228
+ this.statuses.set(name, { state: "idle" });
229
+ }
230
+ }
231
+ /** Destroy all sessions */
232
+ async shutdown() {
233
+ for (const [name, sessionId] of this.sessions) {
234
+ if (this.adapter.destroySession) {
235
+ await this.adapter.destroySession(sessionId);
236
+ }
237
+ }
238
+ this.sessions.clear();
239
+ }
240
+ }
@@ -0,0 +1 @@
1
+ export {};