@adaptic/maestro 1.7.3 → 1.8.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.
Files changed (36) hide show
  1. package/.claude/commands/init-maestro.md +15 -2
  2. package/.gitignore +7 -0
  3. package/README.md +62 -11
  4. package/bin/maestro.mjs +338 -2
  5. package/bin/maestro.test.mjs +299 -0
  6. package/docs/guides/poller-daemon-setup.md +21 -8
  7. package/docs/runbooks/perpetual-operations.md +19 -15
  8. package/docs/runbooks/recovery-and-failover.md +42 -0
  9. package/lib/cadence-bus.mjs +625 -0
  10. package/lib/cadence-bus.test.mjs +354 -0
  11. package/package.json +6 -1
  12. package/scaffold/CLAUDE.md +11 -7
  13. package/scripts/cadence/cadence-status.mjs +36 -0
  14. package/scripts/cadence/enqueue-cadence-tick.mjs +158 -0
  15. package/scripts/cadence/enqueue-cadence-tick.test.mjs +154 -0
  16. package/scripts/cadence/launchd-cadence-wrapper.sh +85 -0
  17. package/scripts/daemon/cadence-consumer.mjs +439 -0
  18. package/scripts/daemon/cadence-consumer.test.mjs +397 -0
  19. package/scripts/daemon/cadence-handlers.mjs +263 -0
  20. package/scripts/daemon/maestro-daemon.mjs +20 -0
  21. package/scripts/local-triggers/generate-plists.sh +33 -12
  22. package/scripts/local-triggers/generate-plists.test.mjs +185 -0
  23. package/scripts/local-triggers/plists/.gitkeep +0 -0
  24. package/scripts/local-triggers/run-trigger.sh +22 -3
  25. package/scripts/local-triggers/plists/ai.adaptic.sophie-backlog-executor.plist +0 -21
  26. package/scripts/local-triggers/plists/ai.adaptic.sophie-daemon.plist +0 -32
  27. package/scripts/local-triggers/plists/ai.adaptic.sophie-inbox-processor.plist +0 -21
  28. package/scripts/local-triggers/plists/ai.adaptic.sophie-meeting-action-capture.plist +0 -21
  29. package/scripts/local-triggers/plists/ai.adaptic.sophie-meeting-prep.plist +0 -21
  30. package/scripts/local-triggers/plists/ai.adaptic.sophie-midday-sweep.plist +0 -26
  31. package/scripts/local-triggers/plists/ai.adaptic.sophie-quarterly-self-assessment.plist +0 -62
  32. package/scripts/local-triggers/plists/ai.adaptic.sophie-weekly-engineering-health.plist +0 -28
  33. package/scripts/local-triggers/plists/ai.adaptic.sophie-weekly-execution.plist +0 -28
  34. package/scripts/local-triggers/plists/ai.adaptic.sophie-weekly-hiring.plist +0 -28
  35. package/scripts/local-triggers/plists/ai.adaptic.sophie-weekly-priorities.plist +0 -28
  36. package/scripts/local-triggers/plists/ai.adaptic.sophie-weekly-strategic-memo.plist +0 -28
@@ -0,0 +1,354 @@
1
+ /**
2
+ * cadence-bus.test.mjs — node:test coverage for the cadence bus primitive.
3
+ *
4
+ * Every test creates its own AGENT_ROOT under tmpdir() and cleans up.
5
+ * No real network, no real Claude. Time is injectable via `now` arguments
6
+ * where the API supports it; otherwise tests use real wall-clock with
7
+ * generous epsilons.
8
+ */
9
+
10
+ import { test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { promises as fsp } from "fs";
13
+ import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
14
+ import { tmpdir } from "os";
15
+ import { join } from "path";
16
+
17
+ import {
18
+ enqueueTick,
19
+ claimNextTick,
20
+ completeTick,
21
+ failTick,
22
+ recoverStaleClaims,
23
+ writeHealth,
24
+ readHealth,
25
+ busDepth,
26
+ ensureBusDirs,
27
+ bootstrapBus,
28
+ getBusPaths,
29
+ nextEventId,
30
+ listInbox,
31
+ listClaimed,
32
+ BUS_VERSION,
33
+ } from "./cadence-bus.mjs";
34
+
35
+ async function makeAgentRoot() {
36
+ const path = join(
37
+ tmpdir(),
38
+ `cadence-bus-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
39
+ );
40
+ await fsp.mkdir(path, { recursive: true });
41
+ return path;
42
+ }
43
+
44
+ async function rmRoot(path) {
45
+ try { await fsp.rm(path, { recursive: true, force: true }); } catch { /* */ }
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Path resolution + bootstrap
50
+ // ---------------------------------------------------------------------------
51
+
52
+ test("getBusPaths derives every path under state/cadence-bus/", async () => {
53
+ const root = await makeAgentRoot();
54
+ try {
55
+ const p = getBusPaths(root);
56
+ assert.equal(p.agentRoot, root);
57
+ assert.equal(p.base, join(root, "state/cadence-bus"));
58
+ assert.equal(p.inbox, join(root, "state/cadence-bus/inbox"));
59
+ assert.equal(p.claimed, join(root, "state/cadence-bus/claimed"));
60
+ assert.equal(p.processed, join(root, "state/cadence-bus/processed"));
61
+ assert.equal(p.dlq, join(root, "state/cadence-bus/dlq"));
62
+ assert.equal(p.queueJsonl, join(root, "state/cadence-bus/queue.jsonl"));
63
+ assert.equal(p.health, join(root, "state/cadence-bus/health.json"));
64
+ assert.equal(p.logsDir, join(root, "logs/cadence-bus"));
65
+ } finally { await rmRoot(root); }
66
+ });
67
+
68
+ test("ensureBusDirs is idempotent and writes VERSION marker", async () => {
69
+ const root = await makeAgentRoot();
70
+ try {
71
+ const p = ensureBusDirs(root);
72
+ assert.ok(existsSync(p.inbox));
73
+ assert.ok(existsSync(p.claimed));
74
+ assert.ok(existsSync(p.processed));
75
+ assert.ok(existsSync(p.failed));
76
+ assert.ok(existsSync(p.dlq));
77
+ assert.ok(existsSync(p.logsDir));
78
+ assert.equal(readFileSync(p.version, "utf-8").trim(), BUS_VERSION);
79
+ // Calling again must not throw and must not duplicate the VERSION file.
80
+ ensureBusDirs(root);
81
+ ensureBusDirs(root);
82
+ assert.equal(readFileSync(p.version, "utf-8").trim(), BUS_VERSION);
83
+ } finally { await rmRoot(root); }
84
+ });
85
+
86
+ test("bootstrapBus writes .gitkeep stubs in every bus dir", async () => {
87
+ const root = await makeAgentRoot();
88
+ try {
89
+ const p = bootstrapBus(root);
90
+ for (const d of [p.inbox, p.claimed, p.processed, p.failed, p.dlq, p.logsDir]) {
91
+ assert.ok(existsSync(join(d, ".gitkeep")), `.gitkeep missing in ${d}`);
92
+ }
93
+ } finally { await rmRoot(root); }
94
+ });
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Event ids
98
+ // ---------------------------------------------------------------------------
99
+
100
+ test("nextEventId yields unique sortable ids", () => {
101
+ const ids = new Set();
102
+ for (let i = 0; i < 200; i++) ids.add(nextEventId());
103
+ assert.equal(ids.size, 200);
104
+ // Sortability: with a fixed clock, the ISO prefix is identical so the
105
+ // hex suffix breaks ties — pass an explicit clock to check.
106
+ const a = nextEventId(new Date(0));
107
+ const b = nextEventId(new Date(0));
108
+ assert.notEqual(a, b);
109
+ assert.match(a, /^evt-1970-01-01T00:00:00\.000Z-[0-9a-f]{8}$/);
110
+ });
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Enqueue
114
+ // ---------------------------------------------------------------------------
115
+
116
+ test("enqueueTick rejects missing cadence", async () => {
117
+ const root = await makeAgentRoot();
118
+ try {
119
+ assert.throws(() => enqueueTick({ agentRoot: root }), /cadence is required/);
120
+ } finally { await rmRoot(root); }
121
+ });
122
+
123
+ test("enqueueTick writes inbox JSON + queue.jsonl audit row", async () => {
124
+ const root = await makeAgentRoot();
125
+ try {
126
+ const out = enqueueTick({
127
+ cadence: "inbox-processor",
128
+ source: "launchd",
129
+ metadata: { note: "smoke" },
130
+ agentRoot: root,
131
+ });
132
+ assert.equal(out.fallbackOnly, false);
133
+ assert.ok(out.path);
134
+ const event = JSON.parse(readFileSync(out.path, "utf-8"));
135
+ assert.equal(event.cadence, "inbox-processor");
136
+ assert.equal(event.source, "launchd");
137
+ assert.equal(event.metadata.note, "smoke");
138
+ assert.equal(event.priority, "normal");
139
+ assert.equal(event.attempts, 0);
140
+ // queue.jsonl mirror
141
+ const paths = getBusPaths(root);
142
+ const audit = readFileSync(paths.queueJsonl, "utf-8").trim();
143
+ const rec = JSON.parse(audit);
144
+ assert.equal(rec.id, out.id);
145
+ } finally { await rmRoot(root); }
146
+ });
147
+
148
+ test("enqueueTick suppresses when .emergency-stop exists but still audits", async () => {
149
+ const root = await makeAgentRoot();
150
+ try {
151
+ writeFileSync(join(root, ".emergency-stop"), new Date().toISOString());
152
+ const out = enqueueTick({
153
+ cadence: "weekly-strategic-memo",
154
+ source: "launchd",
155
+ agentRoot: root,
156
+ });
157
+ assert.equal(out.skipped, "emergency-stop");
158
+ assert.equal(out.fallbackOnly, true);
159
+ assert.equal(listInbox(root).length, 0);
160
+ const paths = getBusPaths(root);
161
+ const audit = readFileSync(paths.queueJsonl, "utf-8").trim();
162
+ const rec = JSON.parse(audit);
163
+ assert.equal(rec._suppressed, "emergency-stop");
164
+ } finally { await rmRoot(root); }
165
+ });
166
+
167
+ test("enqueueTick coerces unknown priority to 'normal'", async () => {
168
+ const root = await makeAgentRoot();
169
+ try {
170
+ const out = enqueueTick({ cadence: "x", priority: "BOGUS", agentRoot: root });
171
+ const event = JSON.parse(readFileSync(out.path, "utf-8"));
172
+ assert.equal(event.priority, "normal");
173
+ } finally { await rmRoot(root); }
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Claim / complete / fail / DLQ
178
+ // ---------------------------------------------------------------------------
179
+
180
+ test("claimNextTick is atomic — only one claimer wins per event", async () => {
181
+ const root = await makeAgentRoot();
182
+ try {
183
+ enqueueTick({ cadence: "x", agentRoot: root });
184
+ enqueueTick({ cadence: "x", agentRoot: root });
185
+ enqueueTick({ cadence: "x", agentRoot: root });
186
+ assert.equal(listInbox(root).length, 3);
187
+
188
+ const seen = new Set();
189
+ let c;
190
+ while ((c = claimNextTick(root)) !== null) {
191
+ assert.ok(!seen.has(c.event.id), "duplicate claim");
192
+ seen.add(c.event.id);
193
+ }
194
+ assert.equal(seen.size, 3);
195
+ assert.equal(listInbox(root).length, 0);
196
+ assert.equal(listClaimed(root).length, 3);
197
+ } finally { await rmRoot(root); }
198
+ });
199
+
200
+ test("claimNextTick bumps attempts and persists to claimed/", async () => {
201
+ const root = await makeAgentRoot();
202
+ try {
203
+ enqueueTick({ cadence: "x", agentRoot: root });
204
+ const { event, claimedPath } = claimNextTick(root);
205
+ assert.equal(event.attempts, 1);
206
+ const reloaded = JSON.parse(readFileSync(claimedPath, "utf-8"));
207
+ assert.equal(reloaded.attempts, 1);
208
+ } finally { await rmRoot(root); }
209
+ });
210
+
211
+ test("claimNextTick returns null on empty inbox", async () => {
212
+ const root = await makeAgentRoot();
213
+ try {
214
+ assert.equal(claimNextTick(root), null);
215
+ } finally { await rmRoot(root); }
216
+ });
217
+
218
+ test("completeTick archives the claim under processed/<utc-date>/", async () => {
219
+ const root = await makeAgentRoot();
220
+ try {
221
+ enqueueTick({ cadence: "x", agentRoot: root });
222
+ const { event } = claimNextTick(root);
223
+ const dst = completeTick(root, event.id, { decision: "inline", duration_ms: 42 });
224
+ assert.ok(dst);
225
+ const archived = JSON.parse(readFileSync(dst, "utf-8"));
226
+ assert.equal(archived.result.decision, "inline");
227
+ assert.equal(archived.result.duration_ms, 42);
228
+ assert.ok(archived.completed_at);
229
+ assert.equal(listClaimed(root).length, 0);
230
+ } finally { await rmRoot(root); }
231
+ });
232
+
233
+ test("failTick re-queues to inbox while under retry budget", async () => {
234
+ const root = await makeAgentRoot();
235
+ try {
236
+ enqueueTick({ cadence: "x", agentRoot: root });
237
+ const { event } = claimNextTick(root);
238
+ const out = failTick(root, event.id, "transient");
239
+ assert.equal(out.destination, "inbox");
240
+ assert.equal(listInbox(root).length, 1);
241
+ assert.equal(listClaimed(root).length, 0);
242
+ // Reload and confirm last_error set.
243
+ const requeued = JSON.parse(readFileSync(join(getBusPaths(root).inbox, `${event.id}.json`), "utf-8"));
244
+ assert.equal(requeued.last_error, "transient");
245
+ } finally { await rmRoot(root); }
246
+ });
247
+
248
+ test("failTick routes to dlq once attempts hit the budget", async () => {
249
+ const root = await makeAgentRoot();
250
+ try {
251
+ enqueueTick({ cadence: "x", agentRoot: root });
252
+ // Cycle claim/fail until the event lands in dlq.
253
+ let last;
254
+ for (let i = 0; i < 10; i++) {
255
+ const c = claimNextTick(root);
256
+ if (!c) break;
257
+ last = failTick(root, c.event.id, `attempt-${i}`);
258
+ if (last?.destination === "dlq") break;
259
+ }
260
+ assert.equal(last.destination, "dlq");
261
+ assert.equal(listInbox(root).length, 0);
262
+ assert.equal(listClaimed(root).length, 0);
263
+ const dlqDir = getBusPaths(root).dlq;
264
+ assert.ok(readdirSync(dlqDir).length >= 1);
265
+ } finally { await rmRoot(root); }
266
+ });
267
+
268
+ test("failTick { terminal: true } sends straight to dlq", async () => {
269
+ const root = await makeAgentRoot();
270
+ try {
271
+ enqueueTick({ cadence: "x", agentRoot: root });
272
+ const { event } = claimNextTick(root);
273
+ const out = failTick(root, event.id, "no-prompt", { terminal: true });
274
+ assert.equal(out.destination, "dlq");
275
+ } finally { await rmRoot(root); }
276
+ });
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Stale claim recovery
280
+ // ---------------------------------------------------------------------------
281
+
282
+ test("recoverStaleClaims returns aging claims to inbox", async () => {
283
+ const root = await makeAgentRoot();
284
+ try {
285
+ enqueueTick({ cadence: "x", agentRoot: root });
286
+ const { event, claimedPath } = claimNextTick(root);
287
+ // Backdate the claim's mtime by 2 hours.
288
+ const oldMs = Date.now() - 2 * 60 * 60 * 1000;
289
+ await fsp.utimes(claimedPath, new Date(oldMs), new Date(oldMs));
290
+
291
+ const stats = recoverStaleClaims(root, 60 * 60 * 1000); // 1 hour threshold
292
+ assert.equal(stats.scanned, 1);
293
+ assert.equal(stats.recovered, 1);
294
+ assert.equal(listInbox(root).length, 1);
295
+ assert.equal(listClaimed(root).length, 0);
296
+ // The recovered event retains its bumped attempts.
297
+ const requeued = JSON.parse(readFileSync(join(getBusPaths(root).inbox, `${event.id}.json`), "utf-8"));
298
+ assert.ok(requeued.attempts >= 1);
299
+ } finally { await rmRoot(root); }
300
+ });
301
+
302
+ test("recoverStaleClaims leaves fresh claims alone", async () => {
303
+ const root = await makeAgentRoot();
304
+ try {
305
+ enqueueTick({ cadence: "x", agentRoot: root });
306
+ claimNextTick(root);
307
+ const stats = recoverStaleClaims(root, 60 * 60 * 1000);
308
+ assert.equal(stats.scanned, 1);
309
+ assert.equal(stats.recovered, 0);
310
+ } finally { await rmRoot(root); }
311
+ });
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Health
315
+ // ---------------------------------------------------------------------------
316
+
317
+ test("writeHealth + readHealth round-trip", async () => {
318
+ const root = await makeAgentRoot();
319
+ try {
320
+ writeHealth(root, { stats: { foo: 1 } });
321
+ const h = readHealth(root);
322
+ assert.ok(h);
323
+ assert.equal(h.version, BUS_VERSION);
324
+ assert.equal(h.stats.foo, 1);
325
+ assert.ok(h.ts);
326
+ } finally { await rmRoot(root); }
327
+ });
328
+
329
+ test("readHealth returns null when missing or corrupt", async () => {
330
+ const root = await makeAgentRoot();
331
+ try {
332
+ assert.equal(readHealth(root), null);
333
+ ensureBusDirs(root);
334
+ writeFileSync(join(root, "state/cadence-bus/health.json"), "{not json");
335
+ assert.equal(readHealth(root), null);
336
+ } finally { await rmRoot(root); }
337
+ });
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Depth
341
+ // ---------------------------------------------------------------------------
342
+
343
+ test("busDepth reports counts per stage", async () => {
344
+ const root = await makeAgentRoot();
345
+ try {
346
+ enqueueTick({ cadence: "x", agentRoot: root });
347
+ enqueueTick({ cadence: "x", agentRoot: root });
348
+ claimNextTick(root);
349
+ const d = busDepth(root);
350
+ assert.equal(d.inbox, 1);
351
+ assert.equal(d.claimed, 1);
352
+ assert.equal(d.dlq, 0);
353
+ } finally { await rmRoot(root); }
354
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adaptic/maestro",
3
- "version": "1.7.3",
3
+ "version": "1.8.0",
4
4
  "description": "Maestro — Autonomous AI agent operating system. Deploy AI employees on dedicated Mac minis.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,6 +11,7 @@
11
11
  "./tools": "./lib/tool-definitions.js",
12
12
  "./executor": "./lib/action-executor.js",
13
13
  "./singleton": "./lib/singleton.js",
14
+ "./cadence-bus": "./lib/cadence-bus.mjs",
14
15
  "./tts": "./lib/tts.mjs",
15
16
  "./package.json": "./package.json"
16
17
  },
@@ -42,6 +43,10 @@
42
43
  },
43
44
  "always-build-npm": true,
44
45
  "scripts": {
46
+ "test": "node --test lib/cadence-bus.test.mjs scripts/cadence/enqueue-cadence-tick.test.mjs scripts/daemon/cadence-consumer.test.mjs scripts/daemon/lib/session-router.test.mjs scripts/local-triggers/generate-plists.test.mjs bin/maestro.test.mjs",
47
+ "test:cadence": "node --test lib/cadence-bus.test.mjs scripts/cadence/enqueue-cadence-tick.test.mjs scripts/daemon/cadence-consumer.test.mjs",
48
+ "test:cli": "node --test bin/maestro.test.mjs",
49
+ "test:plists": "node --test scripts/local-triggers/generate-plists.test.mjs",
45
50
  "prepublishOnly": "echo 'Publishing @adaptic/maestro...'"
46
51
  },
47
52
  "engines": {
@@ -261,15 +261,19 @@ This agent is powered by the `@adaptic/maestro` framework. Update framework: `np
261
261
  - Daemon detects inbound Slack/Gmail/Calendar events every 60 seconds
262
262
  - Priority events trigger immediate processing
263
263
 
264
- ### Mode 2: Scheduled — Run Cadence Workflows
265
- - Morning brief, midday sweep, evening wrap (daily)
266
- - Domain-specific reviews (weekly)
267
- - Self-assessment (quarterly)
264
+ ### Mode 2: Scheduled — Run Cadence Workflows via the Cadence Bus
265
+ - Launchd plists drop tiny JSON events onto `state/cadence-bus/inbox/` every 5 / 10 / 15 / 30 minutes, daily, weekly, monthly, and quarterly.
266
+ - The persistent daemon (a single, long-running Node process) consumes the bus and decides per cadence:
267
+ - **inline**: handled in-process (heartbeats, housekeeping, queue sweeps where the queues are empty)
268
+ - **guarded**: a cheap pre-check escalates only if there's substantive work
269
+ - **escalate**: spawns a managed sub-session running `schedules/triggers/<cadence>.md` (substantive drafting, multi-step outreach, large audits)
270
+ - Launchd never spawns Claude Code directly for routine cadence ticks. The daemon is the sole orchestrator of cadence housekeeping.
271
+ - Emergency-stop is honoured at both producer (enqueue) and consumer (drain).
272
+ - Full lifecycle is recorded under `logs/cadence-bus/<date>.jsonl`.
268
273
 
269
274
  ### Mode 3: Proactive — Execute the Backlog
270
- - Backlog executor runs every 10 minutes
271
- - Reads all queues, selects top actionable items
272
- - Spawns parallel background agents to execute each item
275
+ - Backlog executor runs every 10 minutes via the cadence bus.
276
+ - The bus consumer guards the tick — if all queues are empty, the tick completes inline. Otherwise it escalates to a managed sub-session that reads queues, selects top items by priority, and spawns parallel sub-agents to execute them.
273
277
 
274
278
  ## Parallel Execution & Agent Teams
275
279
 
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cadence-status.mjs — Print the current cadence-bus depth + heartbeat.
4
+ *
5
+ * Used by `npm run cadence:status` and by `maestro doctor` for a quick
6
+ * snapshot. Reads files only; never mutates state.
7
+ *
8
+ * Output (JSON):
9
+ * {
10
+ * "depth": { "inbox": N, "claimed": N, "dlq": N, "failed": N },
11
+ * "health": { ... } | null,
12
+ * "heartbeat_age_ms": N | null
13
+ * }
14
+ *
15
+ * Exit code is always 0 — health interpretation is the caller's job.
16
+ */
17
+
18
+ import { resolve, dirname } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ const { busDepth, readHealth, getBusPaths } = await import(
23
+ resolve(__dirname, "..", "..", "lib", "cadence-bus.mjs")
24
+ );
25
+
26
+ const paths = getBusPaths();
27
+ const depth = busDepth();
28
+ const health = readHealth();
29
+ const heartbeatAgeMs = health?.ts ? Date.now() - new Date(health.ts).getTime() : null;
30
+
31
+ process.stdout.write(JSON.stringify({
32
+ agent_root: paths.agentRoot,
33
+ depth,
34
+ health,
35
+ heartbeat_age_ms: heartbeatAgeMs,
36
+ }, null, 2) + "\n");
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Maestro — enqueue-cadence-tick.mjs
4
+ *
5
+ * Lightweight cadence-bus enqueue CLI. Designed to be invoked from launchd
6
+ * plists (or manual scripting) in place of `claude --print` per tick. The
7
+ * heavy lifting — running the cadence, deciding whether to spawn a sub-
8
+ * session, etc. — is owned by the persistent Maestro daemon, which consumes
9
+ * events from the bus.
10
+ *
11
+ * Design constraints:
12
+ * • Must finish in well under a second so launchd never piles up.
13
+ * • Must never spawn Claude Code itself.
14
+ * • Must be safe to call when the daemon is down (event still lands on
15
+ * disk and is processed on next consumer startup).
16
+ * • Must honour .emergency-stop (no event lands in inbox/; suppressed
17
+ * event is still recorded in queue.jsonl for audit).
18
+ *
19
+ * Usage:
20
+ * node scripts/cadence/enqueue-cadence-tick.mjs <cadence-name> [options]
21
+ *
22
+ * Options:
23
+ * --source=<label> launchd | manual | daemon | init-maestro | upgrade (default: manual)
24
+ * --workflow=<path> e.g. continuous/inbox-processor
25
+ * --priority=<high|normal|low>
26
+ * --correlation-id=<id>
27
+ * --metadata=<key=value> May be repeated. Comma-separated pairs also accepted.
28
+ * --quiet Suppress stdout JSON line.
29
+ * --help Show this help.
30
+ *
31
+ * Exit codes:
32
+ * 0 — enqueued (or suppressed by emergency-stop, which is still success)
33
+ * 2 — invalid usage / missing required arg
34
+ * 3 — internal failure (disk full, permissions, etc.) — caller should retry
35
+ *
36
+ * Examples:
37
+ * # Launchd plist invokes this every 10 minutes:
38
+ * node scripts/cadence/enqueue-cadence-tick.mjs backlog-executor --source=launchd
39
+ *
40
+ * # Manual smoke-test:
41
+ * node scripts/cadence/enqueue-cadence-tick.mjs inbox-processor --source=manual --metadata=note=smoke
42
+ */
43
+
44
+ import { resolve, dirname } from "node:path";
45
+ import { fileURLToPath } from "node:url";
46
+
47
+ const __dirname = dirname(fileURLToPath(import.meta.url));
48
+
49
+ // Resolve cadence-bus relative to this script so the same enqueue works
50
+ // whether invoked from the framework repo (~/maestro/scripts/cadence/...)
51
+ // or from a generated agent repo (~/<agent>/scripts/cadence/...).
52
+ const { enqueueTick, BUS_VERSION } = await import(
53
+ resolve(__dirname, "..", "..", "lib", "cadence-bus.mjs")
54
+ );
55
+
56
+ function printHelp() {
57
+ process.stdout.write(`enqueue-cadence-tick — Maestro cadence bus producer
58
+
59
+ Usage:
60
+ node scripts/cadence/enqueue-cadence-tick.mjs <cadence-name> [options]
61
+
62
+ Options:
63
+ --source=<label> launchd | manual | daemon | init-maestro | upgrade
64
+ --workflow=<path> e.g. continuous/inbox-processor
65
+ --priority=<high|normal|low>
66
+ --correlation-id=<id>
67
+ --metadata=<key=value> repeatable, or comma-separated pairs
68
+ --quiet suppress JSON output to stdout
69
+ --version print bus version and exit
70
+ --help, -h show this help
71
+
72
+ Exits 0 on success (including emergency-stop suppression).
73
+ `);
74
+ }
75
+
76
+ function parseMetadata(raw) {
77
+ // Accept --metadata=key=value, repeated, or --metadata=k1=v1,k2=v2.
78
+ const out = {};
79
+ for (const item of raw) {
80
+ if (!item) continue;
81
+ for (const pair of String(item).split(",")) {
82
+ const idx = pair.indexOf("=");
83
+ if (idx === -1) continue;
84
+ const k = pair.slice(0, idx).trim();
85
+ const v = pair.slice(idx + 1).trim();
86
+ if (k) out[k] = v;
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function parseArgs(argv) {
93
+ const args = { cadence: null, source: "manual", metadata: [], quiet: false };
94
+ for (const arg of argv) {
95
+ if (arg === "--help" || arg === "-h") return { help: true };
96
+ if (arg === "--version") return { version: true };
97
+ if (arg === "--quiet") { args.quiet = true; continue; }
98
+ if (arg.startsWith("--source=")) { args.source = arg.slice("--source=".length); continue; }
99
+ if (arg.startsWith("--workflow=")) { args.workflow = arg.slice("--workflow=".length); continue; }
100
+ if (arg.startsWith("--priority=")) { args.priority = arg.slice("--priority=".length); continue; }
101
+ if (arg.startsWith("--correlation-id=")) { args.correlation_id = arg.slice("--correlation-id=".length); continue; }
102
+ if (arg.startsWith("--metadata=")) { args.metadata.push(arg.slice("--metadata=".length)); continue; }
103
+ if (arg.startsWith("--")) {
104
+ process.stderr.write(`enqueue-cadence-tick: unknown flag: ${arg}\n`);
105
+ return { invalid: true };
106
+ }
107
+ if (!args.cadence) { args.cadence = arg; continue; }
108
+ process.stderr.write(`enqueue-cadence-tick: unexpected positional arg: ${arg}\n`);
109
+ return { invalid: true };
110
+ }
111
+ return args;
112
+ }
113
+
114
+ const argv = process.argv.slice(2);
115
+ const parsed = parseArgs(argv);
116
+
117
+ if (parsed.help) {
118
+ printHelp();
119
+ process.exit(0);
120
+ }
121
+ if (parsed.version) {
122
+ process.stdout.write(`${BUS_VERSION}\n`);
123
+ process.exit(0);
124
+ }
125
+ if (parsed.invalid) {
126
+ printHelp();
127
+ process.exit(2);
128
+ }
129
+ if (!parsed.cadence) {
130
+ process.stderr.write("enqueue-cadence-tick: <cadence-name> required\n");
131
+ printHelp();
132
+ process.exit(2);
133
+ }
134
+
135
+ try {
136
+ const result = enqueueTick({
137
+ cadence: parsed.cadence,
138
+ source: parsed.source,
139
+ workflow: parsed.workflow,
140
+ priority: parsed.priority,
141
+ correlation_id: parsed.correlation_id,
142
+ metadata: parseMetadata(parsed.metadata),
143
+ });
144
+ if (!parsed.quiet) {
145
+ process.stdout.write(JSON.stringify({
146
+ ok: true,
147
+ id: result.id,
148
+ cadence: parsed.cadence,
149
+ path: result.path,
150
+ fallback_only: result.fallbackOnly,
151
+ skipped: result.skipped || null,
152
+ }) + "\n");
153
+ }
154
+ process.exit(0);
155
+ } catch (err) {
156
+ process.stderr.write(`enqueue-cadence-tick: ${err.message}\n`);
157
+ process.exit(3);
158
+ }