@adaptic/maestro 1.1.8 → 1.4.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.
Files changed (47) hide show
  1. package/.claude/commands/init-maestro.md +304 -8
  2. package/README.md +28 -0
  3. package/bin/maestro.mjs +1 -1
  4. package/docs/guides/agents-observe-setup.md +64 -0
  5. package/docs/guides/ccxray-diagnostics.md +65 -0
  6. package/docs/guides/claude-mem-setup.md +79 -0
  7. package/docs/guides/claude-pace-setup.md +56 -0
  8. package/docs/guides/claudraband-sessions.md +98 -0
  9. package/docs/guides/clawteam-swarm.md +116 -0
  10. package/docs/guides/code-review-graph-setup.md +86 -0
  11. package/docs/guides/self-optimization-pattern.md +82 -0
  12. package/docs/guides/slack-setup.md +4 -2
  13. package/docs/guides/twilio-subaccounts-setup.md +223 -0
  14. package/docs/guides/webhook-relay-setup.md +349 -0
  15. package/package.json +2 -1
  16. package/plugins/maestro-skills/plugin.json +16 -0
  17. package/plugins/maestro-skills/skills/agents-observe.md +110 -0
  18. package/plugins/maestro-skills/skills/ccxray-diagnostics.md +91 -0
  19. package/plugins/maestro-skills/skills/claude-pace.md +61 -0
  20. package/plugins/maestro-skills/skills/code-review-graph.md +99 -0
  21. package/scaffold/CLAUDE.md +64 -0
  22. package/scaffold/config/agent.ts.example +2 -1
  23. package/scaffold/config/known-agents.json +35 -0
  24. package/scripts/daemon/classifier.mjs +264 -50
  25. package/scripts/daemon/dispatcher.mjs +109 -5
  26. package/scripts/daemon/launchd-wrapper-generic.sh +96 -0
  27. package/scripts/daemon/launchd-wrapper-slack-events.sh +37 -0
  28. package/scripts/daemon/launchd-wrapper.sh +91 -0
  29. package/scripts/daemon/lib/session-router.mjs +274 -0
  30. package/scripts/daemon/lib/session-router.test.mjs +295 -0
  31. package/scripts/daemon/prompt-builder.mjs +51 -11
  32. package/scripts/daemon/responder.mjs +234 -19
  33. package/scripts/daemon/session-lock.mjs +194 -0
  34. package/scripts/daemon/sophie-daemon.mjs +16 -2
  35. package/scripts/email-signature.html +20 -4
  36. package/scripts/local-triggers/generate-plists.sh +62 -10
  37. package/scripts/poller/imap-client.mjs +4 -2
  38. package/scripts/poller/slack-poller.mjs +104 -52
  39. package/scripts/setup/init-agent.sh +91 -1
  40. package/scripts/setup/install-dev-tools.sh +150 -0
  41. package/scripts/spawn-session.sh +21 -6
  42. package/workflows/continuous/backlog-executor.yaml +141 -0
  43. package/workflows/daily/evening-wrap.yaml +41 -1
  44. package/workflows/daily/morning-brief.yaml +17 -0
  45. package/workflows/event-driven/agent-failure-investigation.yaml +137 -0
  46. package/workflows/event-driven/pr-review.yaml +104 -0
  47. package/workflows/weekly/engineering-health.yaml +154 -0
@@ -0,0 +1,295 @@
1
+ /**
2
+ * session-router.test.mjs — node:test coverage for the session router.
3
+ *
4
+ * No real subprocess spawns, no real disk writes outside os.tmpdir().
5
+ * Each test creates its own registry path under tmpdir and cleans up.
6
+ */
7
+
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { promises as fsp } from "fs";
11
+ import { tmpdir } from "os";
12
+ import { join } from "path";
13
+
14
+ import { routingKey, createRouter } from "./session-router.mjs";
15
+
16
+ function tmpRegistryPath(suffix = "") {
17
+ const name = `session-router-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${suffix}.json`;
18
+ return join(tmpdir(), name);
19
+ }
20
+
21
+ async function safeUnlink(path) {
22
+ try {
23
+ await fsp.unlink(path);
24
+ } catch {
25
+ /* ignore */
26
+ }
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // 1. routingKey — one assertion per row of memo §4.2.
31
+ // ---------------------------------------------------------------------------
32
+
33
+ test("routingKey — slack threaded message", () => {
34
+ const k = routingKey({
35
+ source: "slack",
36
+ channel: "C0123ABC",
37
+ thread_ts: "1777283277.000100",
38
+ });
39
+ assert.equal(k, "slack:C0123ABC:1777283277.000100");
40
+ });
41
+
42
+ test("routingKey — slack DM (no thread)", () => {
43
+ const k = routingKey({
44
+ source: "slack",
45
+ channel: "D099N1JGKRQ",
46
+ });
47
+ assert.equal(k, "slack:D099N1JGKRQ");
48
+ });
49
+
50
+ test("routingKey — slack channel non-DM (no thread)", () => {
51
+ const k = routingKey({
52
+ source: "slack",
53
+ channel: "C0123ABC",
54
+ ts: "1777283277.000200",
55
+ });
56
+ assert.equal(k, "slack:C0123ABC:1777283277.000200");
57
+ });
58
+
59
+ test("routingKey — gmail", () => {
60
+ const k = routingKey({ source: "gmail", thread_id: "1818abc" });
61
+ assert.equal(k, "gmail:1818abc");
62
+ });
63
+
64
+ test("routingKey — calendar", () => {
65
+ const k = routingKey({ source: "calendar", event_id: "evt_42" });
66
+ assert.equal(k, "calendar:evt_42");
67
+ });
68
+
69
+ test("routingKey — internal queue item", () => {
70
+ const k = routingKey({ source: "internal", id: "ar-20260427-001" });
71
+ assert.equal(k, "internal:ar-20260427-001");
72
+ });
73
+
74
+ test("routingKey — backlog with topic_slug", () => {
75
+ const k = routingKey({
76
+ source: "backlog",
77
+ topic_slug: "session-router-step2",
78
+ id: "as-20260427-x",
79
+ });
80
+ assert.equal(k, "backlog:session-router-step2");
81
+ });
82
+
83
+ test("routingKey — backlog without topic_slug falls back to internal:<id>", () => {
84
+ const k = routingKey({ source: "backlog", id: "as-20260427-fallback" });
85
+ assert.equal(k, "internal:as-20260427-fallback");
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // 2. route() — missing key returns EPHEMERAL.
90
+ // ---------------------------------------------------------------------------
91
+
92
+ test("route — missing key returns EPHEMERAL", async (t) => {
93
+ const path = tmpRegistryPath("-missing");
94
+ t.after(() => safeUnlink(path));
95
+ const router = await createRouter({ registryPath: path });
96
+ const decision = router.route("slack:CNEVERSEEN:0");
97
+ assert.deepEqual(decision, { decision: "EPHEMERAL", resumeId: null });
98
+ });
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // 3. route() — past-TTL entry returns EPHEMERAL_REPLACE.
102
+ // ---------------------------------------------------------------------------
103
+
104
+ test("route — past-TTL entry returns EPHEMERAL_REPLACE", async (t) => {
105
+ const path = tmpRegistryPath("-ttl");
106
+ t.after(() => safeUnlink(path));
107
+ let clock = 1_000_000_000_000;
108
+ const router = await createRouter({
109
+ registryPath: path,
110
+ ttlSeconds: 60,
111
+ now: () => clock,
112
+ });
113
+ await router.touch("slack:DAAA:1", { claudeSessionId: "cli-1", model: "sonnet" });
114
+ // Advance clock past TTL.
115
+ clock += 61 * 1000;
116
+ const decision = router.route("slack:DAAA:1");
117
+ assert.equal(decision.decision, "EPHEMERAL_REPLACE");
118
+ assert.equal(decision.resumeId, null);
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // 4. route() — live, fresh, exit_code 0 returns RESUME with the stored id.
123
+ // ---------------------------------------------------------------------------
124
+
125
+ test("route — live fresh entry returns RESUME with stored claude_session_id", async (t) => {
126
+ const path = tmpRegistryPath("-resume");
127
+ t.after(() => safeUnlink(path));
128
+ let clock = 2_000_000_000_000;
129
+ const router = await createRouter({
130
+ registryPath: path,
131
+ ttlSeconds: 600,
132
+ now: () => clock,
133
+ });
134
+ await router.touch("slack:DRESUME:1", {
135
+ claudeSessionId: "cli-resume-xyz",
136
+ model: "sonnet",
137
+ });
138
+ clock += 30_000; // 30s — well within TTL.
139
+ const decision = router.route("slack:DRESUME:1");
140
+ assert.equal(decision.decision, "RESUME");
141
+ assert.equal(decision.resumeId, "cli-resume-xyz");
142
+ });
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // 5. route() — non-zero last_exit_code returns EPHEMERAL_REPLACE.
146
+ // ---------------------------------------------------------------------------
147
+
148
+ test("route — non-zero last_exit_code returns EPHEMERAL_REPLACE", async (t) => {
149
+ const path = tmpRegistryPath("-exitcode");
150
+ t.after(() => safeUnlink(path));
151
+ let clock = 3_000_000_000_000;
152
+ const router = await createRouter({
153
+ registryPath: path,
154
+ ttlSeconds: 600,
155
+ now: () => clock,
156
+ });
157
+ await router.touch("slack:DEXIT:1", {
158
+ claudeSessionId: "cli-exit",
159
+ model: "sonnet",
160
+ });
161
+ await router.recordExit("slack:DEXIT:1", 1);
162
+ clock += 5_000;
163
+ const decision = router.route("slack:DEXIT:1");
164
+ assert.equal(decision.decision, "EPHEMERAL_REPLACE");
165
+ assert.equal(decision.resumeId, null);
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // 6. touch() — LRU cap enforced; oldest entry evicted on overflow.
170
+ // ---------------------------------------------------------------------------
171
+
172
+ test("touch — LRU cap evicts oldest when at capacity", async (t) => {
173
+ const path = tmpRegistryPath("-lru");
174
+ t.after(() => safeUnlink(path));
175
+ let clock = 4_000_000_000_000;
176
+ const router = await createRouter({
177
+ registryPath: path,
178
+ ttlSeconds: 3600,
179
+ maxLiveSessions: 3,
180
+ now: () => clock,
181
+ });
182
+ for (let i = 1; i <= 3; i++) {
183
+ await router.touch(`slack:DLRU${i}`, {
184
+ claudeSessionId: `cli-${i}`,
185
+ model: "sonnet",
186
+ });
187
+ clock += 1000;
188
+ }
189
+ // At cap. Add a fourth.
190
+ await router.touch("slack:DLRU4", {
191
+ claudeSessionId: "cli-4",
192
+ model: "sonnet",
193
+ });
194
+ const snap = router._readForTests();
195
+ assert.equal(snap.lru.length, 3, "lru should still hold 3 keys");
196
+ assert.ok(!("slack:DLRU1" in snap.sessions), "oldest (DLRU1) evicted");
197
+ assert.ok("slack:DLRU2" in snap.sessions);
198
+ assert.ok("slack:DLRU3" in snap.sessions);
199
+ assert.ok("slack:DLRU4" in snap.sessions);
200
+ });
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // 7. recordExit() — non-zero code flips status to "killed".
204
+ // ---------------------------------------------------------------------------
205
+
206
+ test("recordExit — non-zero code flips status to killed", async (t) => {
207
+ const path = tmpRegistryPath("-killed");
208
+ t.after(() => safeUnlink(path));
209
+ const router = await createRouter({ registryPath: path });
210
+ await router.touch("slack:DKILL:1", {
211
+ claudeSessionId: "cli-kill",
212
+ model: "sonnet",
213
+ });
214
+ await router.recordExit("slack:DKILL:1", 137);
215
+ const snap = router._readForTests();
216
+ assert.equal(snap.sessions["slack:DKILL:1"].status, "killed");
217
+ assert.equal(snap.sessions["slack:DKILL:1"].last_exit_code, 137);
218
+ });
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // 8. evictExpired() — removes only stale entries, returns count.
222
+ // ---------------------------------------------------------------------------
223
+
224
+ test("evictExpired — removes only stale entries and returns count", async (t) => {
225
+ const path = tmpRegistryPath("-evict");
226
+ t.after(() => safeUnlink(path));
227
+ let clock = 5_000_000_000_000;
228
+ const router = await createRouter({
229
+ registryPath: path,
230
+ ttlSeconds: 60,
231
+ now: () => clock,
232
+ });
233
+ await router.touch("slack:DEVICT:OLD", {
234
+ claudeSessionId: "cli-old",
235
+ model: "sonnet",
236
+ });
237
+ // Advance past TTL.
238
+ clock += 120_000;
239
+ await router.touch("slack:DEVICT:NEW", {
240
+ claudeSessionId: "cli-new",
241
+ model: "sonnet",
242
+ });
243
+ // Now: OLD is stale (last_used_at = clock - 120s, ttl = 60s),
244
+ // NEW is fresh.
245
+ const evicted = await router.evictExpired();
246
+ assert.equal(evicted, 1, "one entry should have been evicted");
247
+ const snap = router._readForTests();
248
+ assert.ok(!("slack:DEVICT:OLD" in snap.sessions));
249
+ assert.ok("slack:DEVICT:NEW" in snap.sessions);
250
+ });
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // 9. Registry round-trip — touch two keys, re-create router, state restored.
254
+ // ---------------------------------------------------------------------------
255
+
256
+ test("registry round-trip — second router instance reads persisted state", async (t) => {
257
+ const path = tmpRegistryPath("-roundtrip");
258
+ t.after(() => safeUnlink(path));
259
+ let clock = 6_000_000_000_000;
260
+
261
+ const r1 = await createRouter({
262
+ registryPath: path,
263
+ ttlSeconds: 3600,
264
+ now: () => clock,
265
+ });
266
+ await r1.touch("gmail:thread-A", {
267
+ claudeSessionId: "cli-A",
268
+ model: "sonnet",
269
+ });
270
+ clock += 1000;
271
+ await r1.touch("gmail:thread-B", {
272
+ claudeSessionId: "cli-B",
273
+ model: "opus",
274
+ });
275
+
276
+ // Fresh router instance against the same path.
277
+ const r2 = await createRouter({
278
+ registryPath: path,
279
+ ttlSeconds: 3600,
280
+ now: () => clock,
281
+ });
282
+ const snap = r2._readForTests();
283
+ assert.equal(Object.keys(snap.sessions).length, 2);
284
+ assert.equal(snap.sessions["gmail:thread-A"].claude_session_id, "cli-A");
285
+ assert.equal(snap.sessions["gmail:thread-B"].claude_session_id, "cli-B");
286
+ assert.equal(snap.sessions["gmail:thread-B"].model, "opus");
287
+ // Both keys present in the LRU array.
288
+ assert.ok(snap.lru.includes("gmail:thread-A"));
289
+ assert.ok(snap.lru.includes("gmail:thread-B"));
290
+
291
+ // route() should return RESUME for the persisted entry.
292
+ const dec = r2.route("gmail:thread-A");
293
+ assert.equal(dec.decision, "RESUME");
294
+ assert.equal(dec.resumeId, "cli-A");
295
+ });
@@ -102,10 +102,31 @@ const ACTION_INSTRUCTIONS = {
102
102
  respond: `ACTION: Respond to this message.
103
103
  - Read the message carefully and understand the request
104
104
  - Check for any relevant context in thread history
105
- - Draft and send a response using the appropriate MCP tool (Slack or Gmail)
105
+ - Draft the substantive response directly do NOT re-acknowledge if a holding message was already sent
106
+
107
+ READING / CONTEXT (MCP tools are fine here):
108
+ - Use MCP Slack for reading: \`mcp__claude_ai_Slack__slack_search_messages\`, \`slack_conversations_history\`, \`slack_users_info\`, etc.
109
+ - Use MCP Gmail for reading: \`mcp__claude_ai_Gmail__gmail_search\`, \`gmail_read_email\`, etc.
110
+ - Use MCP Google Calendar for scheduling lookups and event reads
111
+ - These are authorised by the user session and are the preferred way to gather context before composing a reply
112
+
113
+ SENDING (CLI scripts only — MCP writes will be blocked by pre-tool hooks):
114
+ - Slack send: \`./scripts/slack-send.sh "<channel_id>" "<message_text>" --responding_to "<inbound_message_ts>"\`
115
+ - Add \`--thread_ts "<thread_ts>"\` if replying inside an existing thread
116
+ - The channel_id is in the inbox item as \`channel_id\` (starts with C, D, or G)
117
+ - Always pass \`--responding_to\` with the inbound message ts so dedup works
118
+ - Gmail send: \`python3 scripts/send-email-threaded.py "<to>" "<subject>" "<body>" --reply-to-subject "<original_subject>"\`
119
+ - Use \`--attachment <path>\` to include files
120
+ - The \`From\` header and HTML signature are injected automatically
121
+ - SMS send: \`./scripts/send-sms.sh --to "<e164_number>" --body "<text>"\`
122
+ - WhatsApp send: \`./scripts/send-whatsapp.sh --to "whatsapp:<e164_number>" --body "<text>"\`
123
+
124
+ Why split read vs write: MCP tools are ideal for reading because they have full API coverage and the session is already authorised. But writes must go through the CLI scripts so the outbound message carries the agent's own User OAuth Token (xoxp-) and appears as the agent's Slack identity — not tagged as "Sent using @Claude". The pre-tool-use hook \`scripts/hooks/block-mcp-slack-send.sh\` will reject any attempt to call \`slack_send_message\` / \`slack_schedule_message\` / \`slack_send_message_draft\` and remind you to use the CLI. Gmail write hooks behave the same way.
125
+
106
126
  - Match the tone and urgency of the sender
107
127
  - If the sender is CEO, prioritise speed and directness
108
- - If you need to share documents, upload to Google Drive or send as PDF — NEVER reference local file paths`,
128
+ - If you need to share documents, upload to Google Drive or send as PDF — NEVER reference local file paths
129
+ - Verify the send succeeded by checking the script's exit code and stdout before considering the task complete`,
109
130
 
110
131
  draft: `ACTION: Draft a response or document — do NOT send it yet.
111
132
  - Read the message and understand what is being requested
@@ -353,6 +374,23 @@ export async function buildPrompt(item, classResult, options = {}) {
353
374
  parts.push(preamble);
354
375
  parts.push("");
355
376
 
377
+ // 1a. Holding message warning — TOP OF PROMPT so Claude sees it before action instructions.
378
+ // This is the most critical instruction in the prompt: prevents double-replies.
379
+ // We repeat it at section 7a as well, immediately before the action block.
380
+ if (holdingMessage) {
381
+ parts.push("===== STOP — READ THIS FIRST =====");
382
+ parts.push(`A HOLDING MESSAGE has ALREADY been sent to the sender by the daemon. The exact text was:`);
383
+ parts.push(` "${holdingMessage}"`);
384
+ parts.push("");
385
+ parts.push("Your job is to deliver the FULL substantive response or complete the actual work — NOT to acknowledge again.");
386
+ parts.push("- Do NOT start your reply with 'Got it', 'On it', 'Looking into this', 'Will get back to you', 'Thanks for reaching out', or any similar acknowledgment phrase.");
387
+ parts.push("- Do NOT echo what the user asked — they already received the holding note confirming receipt.");
388
+ parts.push("- Open with the actual answer, the actual draft, or the actual finding. Be direct.");
389
+ parts.push("- If after investigation you still cannot deliver a substantive response and need more time, send a SECOND-LEVEL UPDATE (specific blocker, ETA, what you need from the user) — never a generic 'still looking into it'.");
390
+ parts.push("===== END WARNING =====");
391
+ parts.push("");
392
+ }
393
+
356
394
  // 2. Session context
357
395
  parts.push(`Date: ${date}`);
358
396
  parts.push(`Classification: priority=${classResult.priority}, action=${action}, category=${classResult.category || "unclassified"}`);
@@ -392,6 +430,12 @@ export async function buildPrompt(item, classResult, options = {}) {
392
430
  }
393
431
 
394
432
  // 5. Action instructions
433
+ // If a holding message was already sent, prepend a second reminder so the
434
+ // action block is unambiguous about not re-acknowledging.
435
+ if (holdingMessage) {
436
+ parts.push("REMINDER: A holding message was already sent (see top of prompt). The action below describes WHAT to do — but you must NOT begin your reply with another acknowledgment. Open with substance.");
437
+ parts.push("");
438
+ }
395
439
  parts.push(actionBlock);
396
440
  parts.push("");
397
441
 
@@ -402,22 +446,18 @@ export async function buildPrompt(item, classResult, options = {}) {
402
446
  parts.push("");
403
447
  }
404
448
 
405
- // 7. Backlog-specific: queue update instructions
449
+ // 7. Backlog-specific: queue update instructions (with history dedup — ib-20260405-001)
406
450
  if (type === "backlog" && queueItem) {
407
451
  parts.push(`When this task is complete:
408
452
  - Update the queue item status to "resolved" in the appropriate state/queues/ file
409
- - Add a history entry with timestamp, action taken, and by: sophie-daemon
453
+ - HISTORY DEDUP RULE (CRITICAL): Before appending a history entry, read the item's existing history and check the LAST entry. If the last entry has substantially the same action text (same status, same priority, same blocked_by, same conclusion — e.g. "confirmed no changes", "still open", "re-verified"), do NOT append a new history entry. Instead, ONLY update the last_updated timestamp on the queue item. Only append a new history entry when something actually changed (status changed, new information, blocker resolved, action taken that differs from the last entry).
454
+ - When you DO add a new history entry, include: timestamp, action taken, and by: sophie-daemon
410
455
  - If the task cannot be completed, set status to "blocked" and record what is blocking it`);
411
456
  parts.push("");
412
457
  }
413
458
 
414
- // 8. Holding message context (if one was already sent)
415
- if (holdingMessage) {
416
- parts.push(`IMPORTANT: A holding message has ALREADY been sent to the sender. Do NOT send another acknowledgment. The message sent was:`);
417
- parts.push(`"${holdingMessage}"`);
418
- parts.push(`Now deliver the actual substantive response or complete the requested work. The sender is expecting the real answer, not another "I'm looking into it."`);
419
- parts.push("");
420
- }
459
+ // 8. (Holding message warning is now at the TOP of the prompt — see section 1a.
460
+ // A second reminder is injected before the action block in section 5.)
421
461
 
422
462
  // 9. Audit instruction
423
463
  parts.push(`Log all actions taken to logs/audit/${date}-actions.jsonl as a JSON line with: timestamp, action_type, target, result, session context.`);