@adaptic/maestro 1.1.0 → 1.1.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.
- package/.claude/commands/init-maestro.md +292 -29
- package/.gitignore +29 -0
- package/README.md +228 -344
- package/bin/maestro.mjs +462 -7
- package/docs/guides/agent-persona-setup.md +1 -1
- package/lib/action-executor.js +225 -0
- package/lib/index.js +16 -0
- package/lib/singleton.js +116 -0
- package/lib/tool-definitions.js +510 -0
- package/package.json +10 -1
- package/scaffold/CLAUDE.md +207 -0
- package/scripts/daemon/maestro-daemon.mjs +37 -0
- package/scripts/local-triggers/generate-plists.sh +235 -0
- package/scripts/local-triggers/install-all.sh +18 -12
- package/scripts/setup/init-agent.sh +58 -27
- package/scripts/slack-events-server.mjs +10 -0
- package/scripts/local-triggers/plists/ai.adaptic.slack-events-server.plist +0 -45
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maestro — Action Executor
|
|
3
|
+
*
|
|
4
|
+
* Executes tool calls from the Claude API. Write tools run agent-local
|
|
5
|
+
* scripts; lookup/RAG tools run targeted `claude --print` queries that
|
|
6
|
+
* leverage MCP servers (Gmail, Calendar, Slack, filesystem, etc.)
|
|
7
|
+
* via the agent's Max subscription.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* import { executeAction } from "@adaptic/maestro/lib/action-executor.js";
|
|
11
|
+
* const result = await executeAction("search_email", { query: "..." }, callerInfo, sessionId);
|
|
12
|
+
*
|
|
13
|
+
* @module action-executor
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { getToolNamesForAccessLevel } from "./tool-definitions.js";
|
|
20
|
+
|
|
21
|
+
const AGENT_ROOT = process.env.AGENT_ROOT || process.cwd();
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Access Control
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
function isAuthorized(toolName, accessLevel) {
|
|
28
|
+
return getToolNamesForAccessLevel(accessLevel).includes(toolName);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Public API
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Execute a tool call.
|
|
37
|
+
* @param {string} toolName
|
|
38
|
+
* @param {object} input
|
|
39
|
+
* @param {object} callerInfo - { slug, name, accessLevel }
|
|
40
|
+
* @param {string} sessionId
|
|
41
|
+
* @param {object} [options] - { agentRoot, scripts }
|
|
42
|
+
* @returns {Promise<{success: boolean, result: string}>}
|
|
43
|
+
*/
|
|
44
|
+
export async function executeAction(toolName, input, callerInfo, sessionId, options = {}) {
|
|
45
|
+
const agentRoot = options.agentRoot || AGENT_ROOT;
|
|
46
|
+
|
|
47
|
+
if (!isAuthorized(toolName, callerInfo.accessLevel)) {
|
|
48
|
+
return { success: false, result: "You do not have permission to perform this action." };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Lookup tools — RAG via Claude Code CLI
|
|
53
|
+
if (LOOKUP_PROMPTS[toolName]) {
|
|
54
|
+
return await executeLookup(toolName, input, agentRoot);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Write tools — delegate to agent-local scripts
|
|
58
|
+
switch (toolName) {
|
|
59
|
+
case "slack_send":
|
|
60
|
+
return await executeScript(agentRoot, "slack-send.sh", [input.recipient, input.message]);
|
|
61
|
+
case "draft_email":
|
|
62
|
+
return await executeDraft(agentRoot, input);
|
|
63
|
+
case "whatsapp_send":
|
|
64
|
+
return await executeScript(agentRoot, "send-whatsapp.sh", [input.recipient, input.message]);
|
|
65
|
+
case "sms_send":
|
|
66
|
+
return await executeScript(agentRoot, "send-sms.sh", [input.recipient, input.message]);
|
|
67
|
+
case "queue_update":
|
|
68
|
+
return await executeQueueUpdate(agentRoot, input);
|
|
69
|
+
case "create_action_item":
|
|
70
|
+
return await executeCreateItem(agentRoot, input);
|
|
71
|
+
case "schedule_meeting":
|
|
72
|
+
return await executeLookup("schedule_meeting", input, agentRoot);
|
|
73
|
+
case "create_reminder":
|
|
74
|
+
return await executeLookup("create_reminder", input, agentRoot);
|
|
75
|
+
case "generate_memo":
|
|
76
|
+
return await executeGenerateMemo(agentRoot, input);
|
|
77
|
+
case "generate_report":
|
|
78
|
+
return await executeLookup("generate_report", input, agentRoot);
|
|
79
|
+
default:
|
|
80
|
+
return { success: false, result: `Unknown tool: ${toolName}` };
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return { success: false, result: `Action failed: ${err.message}` };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Lookup / RAG via Claude Code CLI
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
const LOOKUP_PROMPTS = {
|
|
92
|
+
search_email: (i) =>
|
|
93
|
+
`Search Gmail for: "${i.query}". Return top ${i.max_results || 5} results with sender, subject, date, 1-sentence summary. Be concise — spoken aloud.`,
|
|
94
|
+
search_calendar: (i) =>
|
|
95
|
+
`Look up calendar: "${i.query}" for ${i.time_range || "this week"}. List events with title, time, attendees. Be concise.`,
|
|
96
|
+
search_files: (i) =>
|
|
97
|
+
`Search operational files for: "${i.query}"${i.scope && i.scope !== "all" ? ` in ${i.scope}/` : ""}. Summarise in 2-4 sentences.`,
|
|
98
|
+
search_meetings: (i) =>
|
|
99
|
+
`Search Granola meeting notes for: "${i.query}"${i.date_range ? ` from ${i.date_range}` : ""}. Summarise key discussions and decisions in 2-4 sentences.`,
|
|
100
|
+
search_slack: (i) =>
|
|
101
|
+
`Search Slack for: "${i.query}"${i.channel ? ` in ${i.channel}` : ""}. Summarise who said what, when. 2-4 sentences.`,
|
|
102
|
+
search_web: (i) =>
|
|
103
|
+
`Web search: "${i.query}". Summarise top ${i.max_results || 5} results concisely.`,
|
|
104
|
+
search_documents: (i) =>
|
|
105
|
+
`Search shared documents for: "${i.query}"${i.doc_type && i.doc_type !== "all" ? ` (${i.doc_type} only)` : ""}. List titles and brief summaries.`,
|
|
106
|
+
search_decisions: (i) =>
|
|
107
|
+
`Search the decision archive for: "${i.query}". Include rationale, who decided, and outcome. 2-4 sentences.`,
|
|
108
|
+
lookup_person: (i) =>
|
|
109
|
+
`Look up information about ${i.name}: ${i.info_type || "all available info"} — role, contact, recent activity. Be concise.`,
|
|
110
|
+
search_regulatory: (i) =>
|
|
111
|
+
`Search regulatory/compliance info for: "${i.query}"${i.jurisdiction ? ` in ${i.jurisdiction}` : ""}. Status, deadlines, requirements. Be concise.`,
|
|
112
|
+
search_strategy: (i) =>
|
|
113
|
+
`Search strategic context for: "${i.query}". Priorities, OKRs, initiative status. 2-4 sentences.`,
|
|
114
|
+
search_precedents: (i) =>
|
|
115
|
+
`Search operational precedents for: "${i.query}". How was this handled before? Templates? 2-4 sentences.`,
|
|
116
|
+
search_financial: (i) =>
|
|
117
|
+
`Search financial data for: "${i.query}". Runway, burn, capital readiness. Be concise.`,
|
|
118
|
+
search_pipeline: (i) =>
|
|
119
|
+
`Search pipeline for: "${i.query}"${i.stage && i.stage !== "all" ? ` (stage: ${i.stage})` : ""}. Deal status, partner conversations. Be concise.`,
|
|
120
|
+
search_engineering: (i) =>
|
|
121
|
+
`Search engineering health for: "${i.query}"${i.repo ? ` in ${i.repo}` : ""}. PRs, delivery confidence, blockers. Be concise.`,
|
|
122
|
+
search_hiring: (i) =>
|
|
123
|
+
`Search hiring pipeline for: "${i.query}"${i.status && i.status !== "all" ? ` (status: ${i.status})` : ""}. Roles, candidates, offers. Be concise.`,
|
|
124
|
+
search_risk: (i) =>
|
|
125
|
+
`Search risk register for: "${i.query}"${i.severity && i.severity !== "all" ? ` (severity: ${i.severity})` : ""}. Risks, mitigations, owners. Be concise.`,
|
|
126
|
+
schedule_meeting: (i) =>
|
|
127
|
+
`Schedule a meeting: "${i.title}" with ${i.attendees} on ${i.date}${i.time ? ` at ${i.time}` : ""}${i.duration ? ` for ${i.duration}` : ""}. Check availability and create the event. Confirm what was scheduled.`,
|
|
128
|
+
create_reminder: (i) =>
|
|
129
|
+
`Create a reminder: "${i.message}" for ${i.for_whom || "the principal"} at ${i.when}. Confirm when it will fire.`,
|
|
130
|
+
generate_report: (i) =>
|
|
131
|
+
`Generate a ${i.report_type} report${i.custom_query ? `: ${i.custom_query}` : ""}${i.time_range ? ` for ${i.time_range}` : ""}. Aggregate data from dashboards and queues. Format as a concise executive summary.`,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
async function executeLookup(toolName, input, agentRoot) {
|
|
135
|
+
const promptBuilder = LOOKUP_PROMPTS[toolName];
|
|
136
|
+
if (!promptBuilder) return { success: false, result: `No prompt for: ${toolName}` };
|
|
137
|
+
|
|
138
|
+
const prompt = promptBuilder(input);
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const { stdout } = await new Promise((resolve, reject) => {
|
|
142
|
+
execFile("claude", ["--print", "--model", "haiku", "-p", prompt], {
|
|
143
|
+
timeout: 25000,
|
|
144
|
+
maxBuffer: 1024 * 1024,
|
|
145
|
+
env: { ...process.env, TERM: "dumb" },
|
|
146
|
+
cwd: agentRoot,
|
|
147
|
+
}, (err, stdout, stderr) => {
|
|
148
|
+
if (err) reject(err);
|
|
149
|
+
else resolve({ stdout, stderr });
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return { success: true, result: (stdout || "").trim() || "No results found." };
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (err.killed) return { success: false, result: "Search timed out. Try a more specific query." };
|
|
156
|
+
return { success: false, result: `Search failed: ${err.message}` };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Write tool executors (generic implementations — agents can override)
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
async function executeScript(agentRoot, scriptName, args) {
|
|
165
|
+
const scriptPath = path.join(agentRoot, "scripts", scriptName);
|
|
166
|
+
if (!fs.existsSync(scriptPath)) {
|
|
167
|
+
return { success: false, result: `Script not found: ${scriptName}` };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return new Promise((resolve) => {
|
|
171
|
+
execFile("bash", [scriptPath, ...args.filter(Boolean)], {
|
|
172
|
+
timeout: 15000,
|
|
173
|
+
cwd: agentRoot,
|
|
174
|
+
env: { ...process.env },
|
|
175
|
+
}, (err, stdout, stderr) => {
|
|
176
|
+
if (err) resolve({ success: false, result: stderr || err.message });
|
|
177
|
+
else resolve({ success: true, result: stdout.trim() || "Done." });
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function executeDraft(agentRoot, input) {
|
|
183
|
+
const draftsDir = path.join(agentRoot, "outputs/drafts");
|
|
184
|
+
fs.mkdirSync(draftsDir, { recursive: true });
|
|
185
|
+
|
|
186
|
+
const date = new Date().toISOString().split("T")[0];
|
|
187
|
+
const slug = input.to.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase();
|
|
188
|
+
const filePath = path.join(draftsDir, `${date}-draft-${slug}.md`);
|
|
189
|
+
|
|
190
|
+
const content = `---\nto: ${input.to}\nsubject: ${input.subject}\nvoice: ${input.voice || "agent"}\nurgency: ${input.urgency || "routine"}\ndate: ${new Date().toISOString()}\n---\n\n${input.body}\n`;
|
|
191
|
+
fs.writeFileSync(filePath, content);
|
|
192
|
+
|
|
193
|
+
if (input.urgency === "immediate") {
|
|
194
|
+
return executeScript(agentRoot, "send-email.sh", [input.to, input.subject, input.body]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { success: true, result: `Draft saved: ${filePath}` };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function executeQueueUpdate(agentRoot, input) {
|
|
201
|
+
// Delegate to claude CLI for intelligent queue searching and updating
|
|
202
|
+
return executeLookup("queue_update_internal", input, agentRoot);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Add queue_update_internal to LOOKUP_PROMPTS
|
|
206
|
+
LOOKUP_PROMPTS.queue_update_internal = (i) =>
|
|
207
|
+
`In the state/queues/ directory, find the item matching "${i.search_term}" and update its status to "${i.new_status}"${i.new_priority ? ` and priority to "${i.new_priority}"` : ""}${i.note ? `. Add note: "${i.note}"` : ""}. Confirm what was updated.`;
|
|
208
|
+
|
|
209
|
+
async function executeCreateItem(agentRoot, input) {
|
|
210
|
+
return executeLookup("create_item_internal", input, agentRoot);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
LOOKUP_PROMPTS.create_item_internal = (i) =>
|
|
214
|
+
`Create a new item in state/queues/${i.queue || "action-stack"}.yaml with title "${i.title}", priority "${i.priority || "high"}"${i.due_date ? `, due ${i.due_date}` : ""}${i.owner ? `, owner: ${i.owner}` : ""}${i.context ? `, context: "${i.context}"` : ""}. Use the existing YAML format. Confirm what was created.`;
|
|
215
|
+
|
|
216
|
+
async function executeGenerateMemo(agentRoot, input) {
|
|
217
|
+
const draftsDir = path.join(agentRoot, "outputs/drafts");
|
|
218
|
+
fs.mkdirSync(draftsDir, { recursive: true });
|
|
219
|
+
|
|
220
|
+
const slug = input.title.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase().slice(0, 40);
|
|
221
|
+
const filePath = path.join(draftsDir, `${new Date().toISOString().split("T")[0]}-${slug}.md`);
|
|
222
|
+
fs.writeFileSync(filePath, `# ${input.title}\n\n${input.content}\n`);
|
|
223
|
+
|
|
224
|
+
return { success: true, result: `Memo saved to ${filePath}` };
|
|
225
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @adaptic/maestro — Main entry point
|
|
3
|
+
*
|
|
4
|
+
* Re-exports all public modules for convenient consumption:
|
|
5
|
+
* import { getToolsForAccessLevel, executeAction } from "@adaptic/maestro";
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
getToolsForAccessLevel,
|
|
10
|
+
getToolNamesForAccessLevel,
|
|
11
|
+
getAllToolsByCategory,
|
|
12
|
+
} from "./tool-definitions.js";
|
|
13
|
+
|
|
14
|
+
export { executeAction } from "./action-executor.js";
|
|
15
|
+
|
|
16
|
+
export { acquireLock, releaseLock, checkLock } from "./singleton.js";
|
package/lib/singleton.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maestro — Process Singleton Guard
|
|
3
|
+
*
|
|
4
|
+
* Prevents duplicate instances of long-running services (daemon, events
|
|
5
|
+
* server, huddle server, etc.). Uses PID files with stale detection.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { acquireLock, releaseLock } from "@adaptic/maestro/lib/singleton.js";
|
|
9
|
+
*
|
|
10
|
+
* // At startup:
|
|
11
|
+
* if (!acquireLock("slack-events")) {
|
|
12
|
+
* console.log("Already running — exiting");
|
|
13
|
+
* process.exit(0);
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* // On shutdown:
|
|
17
|
+
* process.on("SIGTERM", () => { releaseLock("slack-events"); process.exit(0); });
|
|
18
|
+
*
|
|
19
|
+
* @module singleton
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { writeFileSync, readFileSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { execSync } from "node:child_process";
|
|
25
|
+
|
|
26
|
+
const AGENT_ROOT = process.env.AGENT_ROOT || process.cwd();
|
|
27
|
+
const LOCKS_DIR = join(AGENT_ROOT, "state", "locks", "process");
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Attempt to acquire a singleton lock for a named service.
|
|
31
|
+
* Returns true if the lock was acquired (no other instance running).
|
|
32
|
+
* Returns false if another instance is already running.
|
|
33
|
+
*
|
|
34
|
+
* Handles stale PID files (process died without cleanup).
|
|
35
|
+
*
|
|
36
|
+
* @param {string} name - Service name (e.g., "slack-events", "daemon", "huddle-server")
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
export function acquireLock(name) {
|
|
40
|
+
mkdirSync(LOCKS_DIR, { recursive: true });
|
|
41
|
+
const pidFile = join(LOCKS_DIR, `${name}.pid`);
|
|
42
|
+
|
|
43
|
+
// Check for existing lock
|
|
44
|
+
if (existsSync(pidFile)) {
|
|
45
|
+
try {
|
|
46
|
+
const existingPid = parseInt(readFileSync(pidFile, "utf8").trim(), 10);
|
|
47
|
+
if (existingPid && isProcessRunning(existingPid)) {
|
|
48
|
+
// Another instance is genuinely running
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
// Stale PID file — process is dead, clean up
|
|
52
|
+
unlinkSync(pidFile);
|
|
53
|
+
} catch {
|
|
54
|
+
// Corrupt PID file — remove it
|
|
55
|
+
try { unlinkSync(pidFile); } catch { /* ignore */ }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Write our PID
|
|
60
|
+
writeFileSync(pidFile, String(process.pid));
|
|
61
|
+
|
|
62
|
+
// Register cleanup on exit
|
|
63
|
+
const cleanup = () => {
|
|
64
|
+
try { unlinkSync(pidFile); } catch { /* ignore */ }
|
|
65
|
+
};
|
|
66
|
+
process.on("exit", cleanup);
|
|
67
|
+
process.on("SIGINT", () => { cleanup(); process.exit(0); });
|
|
68
|
+
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
|
69
|
+
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Manually release a lock.
|
|
75
|
+
* @param {string} name
|
|
76
|
+
*/
|
|
77
|
+
export function releaseLock(name) {
|
|
78
|
+
const pidFile = join(LOCKS_DIR, `${name}.pid`);
|
|
79
|
+
try { unlinkSync(pidFile); } catch { /* ignore */ }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if a service is currently running.
|
|
84
|
+
* @param {string} name
|
|
85
|
+
* @returns {{ running: boolean, pid: number|null }}
|
|
86
|
+
*/
|
|
87
|
+
export function checkLock(name) {
|
|
88
|
+
const pidFile = join(LOCKS_DIR, `${name}.pid`);
|
|
89
|
+
if (!existsSync(pidFile)) return { running: false, pid: null };
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const pid = parseInt(readFileSync(pidFile, "utf8").trim(), 10);
|
|
93
|
+
if (pid && isProcessRunning(pid)) {
|
|
94
|
+
return { running: true, pid };
|
|
95
|
+
}
|
|
96
|
+
// Stale — clean up
|
|
97
|
+
try { unlinkSync(pidFile); } catch { /* ignore */ }
|
|
98
|
+
return { running: false, pid: null };
|
|
99
|
+
} catch {
|
|
100
|
+
return { running: false, pid: null };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check if a PID is still alive.
|
|
106
|
+
* @param {number} pid
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
function isProcessRunning(pid) {
|
|
110
|
+
try {
|
|
111
|
+
process.kill(pid, 0); // Signal 0 = existence check, no actual signal
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|