claude-tempo 0.6.0 → 0.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.
package/CLAUDE.md CHANGED
@@ -30,10 +30,11 @@ src/
30
30
  │ ├── listen.ts # Manual message check
31
31
  │ ├── recruit.ts # Spawn new session
32
32
  │ ├── report.ts # Report to conductor
33
- │ ├── terminate.ts # Terminate a session
33
+ │ ├── stop.ts # Stop a session
34
34
  │ └── helpers.ts # Zod/MCP tool registration wrapper
35
35
  ├── types.ts # Shared type definitions
36
36
  ├── channel.ts # Claude channel notification helper
37
+ ├── git-info.ts # Git repository detection helper
37
38
  └── config.ts # Env var handling
38
39
  ```
39
40
 
@@ -66,8 +67,9 @@ npm test
66
67
  - **Ensemble**: The set of all active players, namespaced by `CLAUDE_TEMPO_ENSEMBLE`
67
68
  - **Cue**: A message sent to a player by name via Temporal signal
68
69
  - **Part**: A player's description of what it's working on
69
- - **Recruit**: Spawning a new Claude Code session as a player
70
+ - **Recruit**: Spawning a new Claude Code session as a player. The workflow is pre-created with the initial message before the process spawns, ensuring reliable delivery.
70
71
  - **set_name**: Players start with a random hex ID; `set_name` updates the `ClaudeTempoPlayerId` search attribute to a human-readable name
72
+ - **Session status**: Each session has a status (`pending` → `active` → `stale`) tracked via `ClaudeTempoStatus` search attribute. Pre-created workflows start as `pending`, transition to `active` when the process connects, and become `stale` if messages go undelivered for 3+ minutes.
71
73
 
72
74
  ## Dashboard
73
75
 
package/README.md CHANGED
@@ -1,6 +1,13 @@
1
- # claude-tempo
2
-
3
- Multi-session [Claude Code](https://claude.ai/code) coordination via [Temporal](https://temporal.io).
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="assets/logo-light.svg">
5
+ <img alt="claude-tempo" src="assets/logo-light.svg" height="140">
6
+ </picture>
7
+ </p>
8
+ <p align="center">
9
+ Multi-session <a href="https://claude.ai/code">Claude Code</a> coordination via <a href="https://temporal.io">Temporal</a>.
10
+ </p>
4
11
 
5
12
  Multiple Claude Code sessions discover each other, exchange messages in real time, and coordinate work — across machines, not just localhost.
6
13
 
@@ -91,7 +98,7 @@ claude-tempo <command> [options]
91
98
 
92
99
  | Command | Description |
93
100
  |---------|-------------|
94
- | `up [ensemble]` | First-time setup: start Temporal, configure MCP, launch conductor |
101
+ | `up [ensemble]` | First-time setup: start Temporal, configure MCP, launch conductor. Use `--from` to load a blueprint. |
95
102
  | `down` | Stop Temporal, terminate sessions, remove MCP config |
96
103
  | `server` | Start the Temporal dev server and register search attributes |
97
104
  | `conduct [ensemble]` | Start a conductor session (one per ensemble). Use `--resume` or `--replace` if one exists. |
@@ -101,6 +108,7 @@ claude-tempo <command> [options]
101
108
  | `stop [ensemble]` | Stop sessions (`-n <name>` for one, `--all` for everything) |
102
109
  | `init` | Register claude-tempo MCP server globally (`--project` for per-directory) |
103
110
  | `preflight` | Run environment checks |
111
+ | `ensemble <sub>` | Manage saved blueprints (`save`, `list`, `show`) |
104
112
  | `help` | Show usage info |
105
113
 
106
114
  ### Global options
@@ -115,6 +123,7 @@ claude-tempo <command> [options]
115
123
  --skip-preflight Skip preflight checks (start/conduct)
116
124
  -d, --dir <path> Target directory (default: cwd)
117
125
  --background Run Temporal in background (server only)
126
+ --from <file> Load an ensemble blueprint on startup (up only)
118
127
  --resume Resume an existing conductor session (conduct only)
119
128
  --replace Stop existing conductor and start fresh (conduct only)
120
129
  ```
@@ -172,7 +181,7 @@ Ensemble: myband
172
181
  Building the REST endpoints
173
182
  /Users/me/projects/app feat/api my-machine.local
174
183
 
175
- bob
184
+ bob (pending)
176
185
  Working on the dashboard
177
186
  /Users/me/projects/app feat/ui my-machine.local
178
187
 
@@ -208,10 +217,12 @@ These tools are available inside Claude Code sessions connected to claude-tempo:
208
217
  | `listen` | Manually check for pending messages. |
209
218
  | `recruit` | Spawn a new Claude Code session in a directory. Can recruit a conductor with `conductor: true`. |
210
219
  | `report` | Send updates to the conductor. No-op if no conductor exists. |
211
- | `terminate` | Terminate a player session by name. |
220
+ | `stop` | Stop a player session by name. |
212
221
  | `schedule` | Create a one-shot or recurring schedule to cue a player. |
213
222
  | `unschedule` | Cancel a named schedule. |
214
223
  | `schedules` | List all active schedules. |
224
+ | `save_ensemble` | Save the current ensemble as a YAML blueprint (conductor only). |
225
+ | `load_ensemble` | Load a blueprint to recruit players and create schedules. |
215
226
 
216
227
  ## Scheduling
217
228
 
@@ -244,6 +255,80 @@ Schedules support one-shot delays, fixed times, and recurring intervals with opt
244
255
  - `claude-tempo status` shows active schedules alongside sessions
245
256
  - A single durable scheduler workflow per ensemble manages all schedules using Temporal timers
246
257
 
258
+ ## Ensemble Blueprints
259
+
260
+ Define reusable ensemble configurations as YAML files. A blueprint specifies which players to recruit, what instructions to give them, what schedules to create, and optionally which custom agent files to use.
261
+
262
+ ### Example blueprint
263
+
264
+ ```yaml
265
+ name: my-project
266
+ conductor:
267
+ instructions: "Coordinate the frontend and backend teams"
268
+ players:
269
+ - name: frontend
270
+ workDir: /repos/my-app
271
+ instructions: "Build the React dashboard in src/components"
272
+ - name: backend
273
+ workDir: /repos/my-api
274
+ instructions: "Implement the REST endpoints in src/routes"
275
+ - name: ops
276
+ workDir: /repos/infra
277
+ agent: agents/ops-agent.md
278
+ instructions: "Monitor deployments and run health checks"
279
+ schedules:
280
+ - name: status-check
281
+ message: "Report your current progress and any blockers"
282
+ target: all
283
+ every: 30m
284
+ - name: deploy-reminder
285
+ message: "Check if the staging deploy succeeded"
286
+ target: ops
287
+ delay: 10m
288
+ ```
289
+
290
+ ### Three ways to use blueprints
291
+
292
+ 1. **From the CLI** — load a blueprint when starting an ensemble:
293
+
294
+ ```bash
295
+ claude-tempo up --from my-blueprint.yaml
296
+ ```
297
+
298
+ 2. **From inside a session** — use the `load_ensemble` tool:
299
+
300
+ *"Load the blueprint from ~/.claude-tempo/ensembles/my-project.yaml"*
301
+
302
+ 3. **Save the current state** — snapshot a running ensemble as a blueprint (conductor only):
303
+
304
+ *"Save this ensemble as a blueprint called my-project"*
305
+
306
+ ### Natural language examples
307
+
308
+ Tell your session things like:
309
+
310
+ - *"Load the my-project blueprint"*
311
+ - *"Save this ensemble as a blueprint"*
312
+ - *"Load the blueprint from /repos/configs/team.yaml"*
313
+
314
+ ### Fan-out schedules
315
+
316
+ Use `target: "all"` in a schedule to deliver a message to every active player (excluding the conductor). This is useful for periodic status checks or broadcast announcements:
317
+
318
+ - *"Schedule a message every 30 minutes to all players asking for a progress update"*
319
+
320
+ ### Custom agents
321
+
322
+ The `agent` field on a player can be a path to a `.md` file that will be used as the session's system prompt via `--system-prompt`. This lets you create specialized agents with domain-specific instructions:
323
+
324
+ ```yaml
325
+ players:
326
+ - name: security-reviewer
327
+ workDir: /repos/my-app
328
+ agent: agents/security-review.md
329
+ instructions: "Review the latest PR for security issues"
330
+ ```
331
+
247
332
  ## Conductors
248
333
 
249
334
  A **conductor** is an optional special player that acts as an orchestration hub. Use one when you want:
@@ -320,6 +405,23 @@ Sessions start with a random 8-character hex ID. Set a name at launch with `-n`
320
405
  - Names must contain only letters, numbers, hyphens, and underscores
321
406
  - The name "conductor" is reserved for conductor sessions
322
407
 
408
+ ### Session status lifecycle
409
+
410
+ Each session has a status that tracks its connection state:
411
+
412
+ | Status | Meaning |
413
+ |--------|---------|
414
+ | `pending` | Workflow created by `recruit`, but the Claude Code process hasn't connected yet |
415
+ | `active` | Session is running and responsive |
416
+ | `stale` | Messages have gone undelivered for 3+ minutes — the session is likely disconnected |
417
+
418
+ Status transitions:
419
+ - **`pending` → `active`** — when the spawned session connects and sends its `updateMetadata` signal
420
+ - **`active` → `stale`** — when undelivered messages exceed the stale threshold (3 minutes)
421
+ - Any status → **terminated** — on graceful shutdown or `stop`
422
+
423
+ `claude-tempo status` shows `(pending)` and `(stale)` indicators next to player names. The `ClaudeTempoStatus` search attribute is also set, so you can filter sessions by status in the Temporal UI (e.g., `ClaudeTempoStatus = "stale"`).
424
+
323
425
  ### Terminal support
324
426
 
325
427
  `recruit` and the CLI detect your terminal automatically:
@@ -331,10 +433,13 @@ Sessions start with a random 8-character hex ID. Set a name at launch with `-n`
331
433
  | Terminal.app | ✓ | — | — |
332
434
  | gnome-terminal | — | ✓ | — |
333
435
  | konsole / xterm | — | ✓ | — |
436
+ | Windows Terminal | — | — | ✓ (tabs) |
334
437
  | cmd.exe / PowerShell | — | — | ✓ |
335
438
 
336
439
  macOS terminals preserve the full shell environment (fish, zsh, bash) including node version managers (fnm, nvm).
337
440
 
441
+ Windows Terminal is detected automatically via the `WT_SESSION` environment variable. When running inside Windows Terminal, recruited sessions open as new tabs (with the player name as the tab title) instead of separate cmd.exe windows.
442
+
338
443
  ## Configuration
339
444
 
340
445
  Run `claude-tempo config` to save Temporal connection settings so you don't need flags or env vars every time:
@@ -10,7 +10,43 @@ function createScheduleActivities(client) {
10
10
  async fireSchedule(input) {
11
11
  const { ensemble, scheduleName, message, target, createdBy } = input;
12
12
  try {
13
- // Resolve target player by querying running session workflows
13
+ const text = `[scheduled: ${scheduleName}] ${message}`;
14
+ // Handle target "all" — deliver to every active player except the conductor
15
+ if (target === 'all') {
16
+ const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
17
+ const failures = [];
18
+ let delivered = 0;
19
+ for await (const wf of client.workflow.list({ query })) {
20
+ try {
21
+ const handle = client.workflow.getHandle(wf.workflowId);
22
+ const metadata = await handle.query('getMetadata');
23
+ if (metadata.ensemble !== ensemble)
24
+ continue;
25
+ if (metadata.isConductor)
26
+ continue; // skip conductor
27
+ await handle.signal('receiveMessage', {
28
+ from: createdBy,
29
+ text,
30
+ isScheduled: true,
31
+ scheduleName,
32
+ });
33
+ delivered++;
34
+ }
35
+ catch (err) {
36
+ const msg = err instanceof Error ? err.message : String(err);
37
+ failures.push(`${wf.workflowId}: ${msg}`);
38
+ }
39
+ }
40
+ if (delivered === 0 && failures.length === 0) {
41
+ await notifyFailure(client, ensemble, createdBy, scheduleName, target, 'No active players found in the ensemble.');
42
+ return { success: false, error: 'No active players found' };
43
+ }
44
+ return {
45
+ success: failures.length === 0,
46
+ error: failures.length > 0 ? `Delivered to ${delivered} players, ${failures.length} failed: ${failures.join('; ')}` : undefined,
47
+ };
48
+ }
49
+ // Resolve single target player by querying running session workflows
14
50
  const handle = await resolveSession(client, ensemble, target);
15
51
  if (!handle) {
16
52
  // Notify the creator (or conductor as fallback) about the failure
@@ -18,7 +54,6 @@ function createScheduleActivities(client) {
18
54
  return { success: false, error: `No active session found for "${target}"` };
19
55
  }
20
56
  // Send cue signal with from set to the original creator's name
21
- const text = `[scheduled: ${scheduleName}] ${message}`;
22
57
  await handle.signal('receiveMessage', {
23
58
  from: createdBy,
24
59
  text,
@@ -27,6 +27,7 @@ export declare function server(opts: ServerOpts): Promise<void>;
27
27
  interface UpOpts extends CliOverrides {
28
28
  ensemble: string;
29
29
  name?: string;
30
+ from?: string;
30
31
  agent: AgentType;
31
32
  }
32
33
  export declare function up(opts: UpOpts): Promise<void>;
@@ -44,6 +45,11 @@ interface StopOpts extends CliOverrides {
44
45
  all?: boolean;
45
46
  }
46
47
  export declare function stop(opts: StopOpts): Promise<void>;
48
+ interface EnsembleCommandOpts extends CliOverrides {
49
+ subcommand?: string;
50
+ name?: string;
51
+ }
52
+ export declare function ensembleCommand(opts: EnsembleCommandOpts): Promise<void>;
47
53
  export declare function help(): void;
48
54
  export declare function version(): void;
49
55
  export {};
@@ -40,6 +40,7 @@ exports.server = server;
40
40
  exports.up = up;
41
41
  exports.down = down;
42
42
  exports.stop = stop;
43
+ exports.ensembleCommand = ensembleCommand;
43
44
  exports.help = help;
44
45
  exports.version = version;
45
46
  const fs_1 = require("fs");
@@ -50,8 +51,11 @@ const spawn_1 = require("../spawn");
50
51
  const config_1 = require("../config");
51
52
  const connection_1 = require("../connection");
52
53
  const signals_1 = require("../workflows/signals");
54
+ const scheduler_signals_1 = require("../workflows/scheduler-signals");
53
55
  const preflight_1 = require("./preflight");
54
56
  const mcp_1 = require("./mcp");
57
+ const loader_1 = require("../ensemble/loader");
58
+ const saver_1 = require("../ensemble/saver");
55
59
  const out = __importStar(require("./output"));
56
60
  /** Package root is two levels up from dist/cli/ */
57
61
  const PACKAGE_ROOT = (0, path_1.resolve)(__dirname, '..', '..');
@@ -93,7 +97,7 @@ async function start(opts) {
93
97
  if (opts.replace) {
94
98
  out.log(`Stopping existing conductor for ensemble "${opts.ensemble}"...`);
95
99
  try {
96
- await handle.signal(signals_1.shutdownSignal);
100
+ await handle.signal(signals_1.updateMetadataSignal, { status: 'terminated' });
97
101
  // Wait briefly for graceful shutdown
98
102
  for (let i = 0; i < 10; i++) {
99
103
  await new Promise(r => setTimeout(r, 500));
@@ -224,6 +228,7 @@ async function status(opts) {
224
228
  host: meta.hostname || '',
225
229
  conductor: meta.isConductor || false,
226
230
  agentType: meta.agentType || 'claude',
231
+ status: meta.status || 'active',
227
232
  });
228
233
  }
229
234
  catch {
@@ -276,8 +281,11 @@ async function status(opts) {
276
281
  for (const s of members) {
277
282
  const role = s.conductor ? out.yellow(' (conductor)') : '';
278
283
  const agent = s.agentType === 'copilot' ? out.dim(' [copilot]') : '';
284
+ const statusLabel = s.status === 'stale' ? out.yellow(' (stale)')
285
+ : s.status === 'pending' ? out.dim(' (pending)')
286
+ : '';
279
287
  const name = out.bold(s.name);
280
- out.log(` ${name}${role}${agent}`);
288
+ out.log(` ${name}${role}${statusLabel}${agent}`);
281
289
  if (s.part)
282
290
  out.log(` ${out.dim(s.part)}`);
283
291
  const details = [s.workDir, s.branch, s.host].filter(Boolean).join(' ');
@@ -378,6 +386,7 @@ const SEARCH_ATTRIBUTES = [
378
386
  { name: 'ClaudeTempoGitRoot', type: 'Keyword' },
379
387
  { name: 'ClaudeTempoEnsemble', type: 'Keyword' },
380
388
  { name: 'ClaudeTempoPlayerId', type: 'Keyword' },
389
+ { name: 'ClaudeTempoStatus', type: 'Keyword' },
381
390
  ];
382
391
  function isTemporalReachable(config) {
383
392
  return (0, connection_1.createTemporalConnection)(config)
@@ -555,11 +564,18 @@ async function up(opts) {
555
564
  temporalEnvVars[config_1.ENV.TEMPORAL_TLS_CERT_PATH] = config.temporalTlsCertPath;
556
565
  if (config.temporalTlsKeyPath)
557
566
  temporalEnvVars[config_1.ENV.TEMPORAL_TLS_KEY_PATH] = config.temporalTlsKeyPath;
567
+ // Load blueprint if --from is provided
568
+ const blueprint = opts.from ? (0, loader_1.loadBlueprint)((0, path_1.resolve)(opts.from)) : undefined;
569
+ if (blueprint) {
570
+ out.check('Blueprint loaded', true, blueprint.name);
571
+ }
572
+ // Resolve conductor agent from blueprint or CLI flags
573
+ const conductorAgent = blueprint?.conductor?.agent === 'copilot' ? 'copilot' : opts.agent;
558
574
  // Step 5: Launch conductor
559
575
  console.log();
560
- out.log(`Launching conductor in ensemble ${out.cyan(opts.ensemble)}${opts.agent === 'copilot' ? out.dim(' (copilot)') : ''}...`);
576
+ out.log(`Launching conductor in ensemble ${out.cyan(opts.ensemble)}${conductorAgent === 'copilot' ? out.dim(' (copilot)') : ''}...`);
561
577
  let pid;
562
- if (opts.agent === 'copilot') {
578
+ if (conductorAgent === 'copilot') {
563
579
  ({ pid } = (0, spawn_1.spawnCopilotBridge)({
564
580
  name: opts.name || `${opts.ensemble}-conductor`,
565
581
  ensemble: opts.ensemble,
@@ -587,16 +603,199 @@ async function up(opts) {
587
603
  [config_1.ENV.PLAYER_NAME]: sessionName,
588
604
  }));
589
605
  }
606
+ out.success(`Conductor launched (pid ${pid ?? 'unknown'})`);
607
+ // Step 6: If blueprint provided, recruit players and create schedules
608
+ if (blueprint) {
609
+ // Connect to Temporal to send signals
610
+ const connection = await (0, connection_1.createTemporalConnection)(config);
611
+ const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
612
+ // Wait for conductor workflow to appear
613
+ out.log(`\n Waiting for conductor to register...`);
614
+ const conductorWfId = (0, config_1.conductorWorkflowId)(opts.ensemble);
615
+ let conductorReady = false;
616
+ for (let i = 0; i < 30; i++) {
617
+ await new Promise(r => setTimeout(r, 500));
618
+ try {
619
+ const handle = client.workflow.getHandle(conductorWfId);
620
+ const desc = await handle.describe();
621
+ if (desc.status.name === 'RUNNING') {
622
+ conductorReady = true;
623
+ break;
624
+ }
625
+ }
626
+ catch { /* not yet */ }
627
+ }
628
+ if (!conductorReady) {
629
+ out.warn('Conductor did not register within 15s — skipping blueprint players/schedules');
630
+ }
631
+ else {
632
+ out.check('Conductor registered', true);
633
+ // Send conductor instructions if provided
634
+ if (blueprint.conductor?.instructions) {
635
+ try {
636
+ const handle = client.workflow.getHandle(conductorWfId);
637
+ await handle.signal('receiveMessage', {
638
+ from: 'blueprint',
639
+ text: blueprint.conductor.instructions,
640
+ });
641
+ out.check('Conductor instructions sent', true);
642
+ }
643
+ catch (err) {
644
+ out.warn(`Could not send conductor instructions: ${err}`);
645
+ }
646
+ }
647
+ // Recruit players sequentially (each polls for ~15s)
648
+ if (blueprint.players.length > 0) {
649
+ console.log();
650
+ out.log(`Recruiting ${blueprint.players.length} player${blueprint.players.length !== 1 ? 's' : ''} from blueprint...`);
651
+ for (const player of blueprint.players) {
652
+ const playerAgent = player.agent === 'copilot' ? 'copilot' : 'claude';
653
+ const playerWorkDir = player.workDir || process.cwd();
654
+ // Record existing workflows to detect the new one
655
+ const existingIds = new Set();
656
+ const listQuery = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
657
+ for await (const wf of client.workflow.list({ query: listQuery })) {
658
+ existingIds.add(wf.workflowId);
659
+ }
660
+ // Spawn the player
661
+ if (playerAgent === 'copilot') {
662
+ (0, spawn_1.spawnCopilotBridge)({
663
+ name: player.name,
664
+ ensemble: opts.ensemble,
665
+ temporalAddress: config.temporalAddress,
666
+ temporalNamespace: config.temporalNamespace,
667
+ temporalApiKey: config.temporalApiKey,
668
+ temporalTlsCertPath: config.temporalTlsCertPath,
669
+ temporalTlsKeyPath: config.temporalTlsKeyPath,
670
+ isConductor: false,
671
+ workDir: playerWorkDir,
672
+ });
673
+ }
674
+ else {
675
+ const claudeArgs = [
676
+ '--dangerously-skip-permissions',
677
+ '--dangerously-load-development-channels', 'server:claude-tempo',
678
+ '-n', player.name,
679
+ ];
680
+ (0, spawn_1.spawnInTerminal)(claudeArgs, playerWorkDir, {
681
+ ...temporalEnvVars,
682
+ [config_1.ENV.ENSEMBLE]: opts.ensemble,
683
+ [config_1.ENV.CONDUCTOR]: '',
684
+ [config_1.ENV.PLAYER_NAME]: player.name,
685
+ });
686
+ }
687
+ // Poll for the new workflow to appear (up to ~15s)
688
+ let newWorkflowId = null;
689
+ for (let attempt = 0; attempt < 30; attempt++) {
690
+ await new Promise(r => setTimeout(r, 500));
691
+ for await (const wf of client.workflow.list({ query: listQuery })) {
692
+ if (!existingIds.has(wf.workflowId)) {
693
+ newWorkflowId = wf.workflowId;
694
+ break;
695
+ }
696
+ }
697
+ if (newWorkflowId)
698
+ break;
699
+ }
700
+ if (newWorkflowId && player.instructions) {
701
+ try {
702
+ const handle = client.workflow.getHandle(newWorkflowId);
703
+ await handle.signal('receiveMessage', {
704
+ from: 'blueprint',
705
+ text: player.instructions,
706
+ });
707
+ }
708
+ catch { /* best effort */ }
709
+ }
710
+ const status = newWorkflowId ? out.green('ok') : out.yellow('slow');
711
+ out.log(` ${status} ${out.bold(player.name)} in ${playerWorkDir}`);
712
+ }
713
+ }
714
+ // Create schedules
715
+ if (blueprint.schedules && blueprint.schedules.length > 0) {
716
+ console.log();
717
+ out.log(`Creating ${blueprint.schedules.length} schedule${blueprint.schedules.length !== 1 ? 's' : ''}...`);
718
+ for (const sched of blueprint.schedules) {
719
+ try {
720
+ const entry = blueprintScheduleToEntry(sched);
721
+ const schedulerWfId = (0, config_1.schedulerWorkflowId)(opts.ensemble);
722
+ const handle = client.workflow.getHandle(schedulerWfId);
723
+ await handle.signal(scheduler_signals_1.addScheduleSignal, entry);
724
+ out.check(sched.name, true, `→ ${sched.target}`);
725
+ }
726
+ catch (err) {
727
+ out.warn(`Could not create schedule "${sched.name}": ${err}`);
728
+ }
729
+ }
730
+ }
731
+ }
732
+ await connection.close();
733
+ }
590
734
  console.log();
591
735
  out.success('You\'re all set!');
592
- out.log(` Conductor launched (pid ${pid ?? 'unknown'})`);
593
736
  out.log(` Ensemble: ${out.cyan(opts.ensemble)}`);
594
- out.log(`\n ${out.bold('What next?')}`);
595
- out.log(` ${out.dim('claude-tempo start ' + opts.ensemble)} Add a player session`);
596
- out.log(` ${out.dim('claude-tempo status ' + opts.ensemble)} See who\'s active`);
597
- out.log(` Or ask the conductor to ${out.dim('recruit')} players for you`);
737
+ if (!blueprint) {
738
+ out.log(`\n ${out.bold('What next?')}`);
739
+ out.log(` ${out.dim('claude-tempo start ' + opts.ensemble)} Add a player session`);
740
+ out.log(` ${out.dim('claude-tempo status ' + opts.ensemble)} See who\'s active`);
741
+ out.log(` Or ask the conductor to ${out.dim('recruit')} players for you`);
742
+ }
743
+ else {
744
+ out.log(` Blueprint: ${out.dim(blueprint.name)}`);
745
+ out.log(` Players: ${blueprint.players.length}`);
746
+ if (blueprint.schedules?.length)
747
+ out.log(` Schedules: ${blueprint.schedules.length}`);
748
+ out.log(`\n ${out.dim('claude-tempo status ' + opts.ensemble)} See who\'s active`);
749
+ }
598
750
  console.log();
599
751
  }
752
+ /** Convert a blueprint schedule definition to a ScheduleEntry for the scheduler workflow. */
753
+ function blueprintScheduleToEntry(sched) {
754
+ const now = Date.now();
755
+ let nextFireAt;
756
+ let interval;
757
+ if (sched.every) {
758
+ interval = parseDuration(sched.every);
759
+ nextFireAt = sched.delay
760
+ ? new Date(now + parseDuration(sched.delay)).toISOString()
761
+ : new Date(now + interval).toISOString();
762
+ }
763
+ else if (sched.at) {
764
+ nextFireAt = new Date(sched.at).toISOString();
765
+ }
766
+ else if (sched.delay) {
767
+ nextFireAt = new Date(now + parseDuration(sched.delay)).toISOString();
768
+ }
769
+ else {
770
+ nextFireAt = new Date(now + 60_000).toISOString(); // default: 1 minute
771
+ }
772
+ return {
773
+ name: sched.name,
774
+ message: sched.message,
775
+ target: sched.target,
776
+ createdBy: 'blueprint',
777
+ nextFireAt,
778
+ interval,
779
+ until: sched.until,
780
+ remainingCount: sched.count,
781
+ firedCount: 0,
782
+ type: interval ? 'interval' : 'once',
783
+ };
784
+ }
785
+ /** Parse a human duration string like "10m", "1h", "30s" to milliseconds. */
786
+ function parseDuration(s) {
787
+ const match = s.match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)$/);
788
+ if (!match)
789
+ throw new Error(`Invalid duration: "${s}"`);
790
+ const value = parseFloat(match[1]);
791
+ switch (match[2]) {
792
+ case 's': return value * 1_000;
793
+ case 'm': return value * 60_000;
794
+ case 'h': return value * 3_600_000;
795
+ case 'd': return value * 86_400_000;
796
+ default: throw new Error(`Unknown duration unit: "${match[2]}"`);
797
+ }
798
+ }
600
799
  async function down(opts) {
601
800
  const config = (0, config_1.getConfig)(opts);
602
801
  out.heading('claude-tempo teardown');
@@ -732,7 +931,7 @@ async function stop(opts) {
732
931
  continue;
733
932
  }
734
933
  }
735
- await handle.signal(signals_1.shutdownSignal);
934
+ await handle.signal(signals_1.updateMetadataSignal, { status: 'terminated' });
736
935
  stopped++;
737
936
  out.log(` ${out.dim('stopped')} ${wf.workflowId}`);
738
937
  }
@@ -792,9 +991,9 @@ async function stopByName(client, name, config, ensemble) {
792
991
  // No conductor or conductor not running — fine
793
992
  }
794
993
  }
795
- // Send shutdown signal (graceful)
994
+ // Send termination status update (graceful)
796
995
  try {
797
- await handle.signal(signals_1.shutdownSignal);
996
+ await handle.signal(signals_1.updateMetadataSignal, { status: 'terminated' });
798
997
  out.success(`Stopped "${name}"`);
799
998
  }
800
999
  catch {
@@ -867,6 +1066,57 @@ function killBridgeProcesses() {
867
1066
  // logs dir unreadable
868
1067
  }
869
1068
  }
1069
+ async function ensembleCommand(opts) {
1070
+ switch (opts.subcommand) {
1071
+ case 'save': {
1072
+ const config = (0, config_1.getConfig)(opts);
1073
+ const connection = await (0, connection_1.createTemporalConnection)(config);
1074
+ const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
1075
+ const ensemble = opts.name || config.ensemble;
1076
+ try {
1077
+ const path = await (0, saver_1.saveBlueprint)(client, ensemble);
1078
+ out.success(`Saved ensemble "${ensemble}" to ${path}`);
1079
+ }
1080
+ finally {
1081
+ await connection.close();
1082
+ }
1083
+ break;
1084
+ }
1085
+ case 'list': {
1086
+ const blueprints = (0, saver_1.listBlueprints)();
1087
+ if (blueprints.length === 0) {
1088
+ out.log('No saved ensembles. Use `claude-tempo ensemble save [name]` to save one.');
1089
+ return;
1090
+ }
1091
+ out.heading('Saved ensembles');
1092
+ for (const bp of blueprints) {
1093
+ out.log(` ${out.bold(bp.name)} ${out.dim(bp.path)}`);
1094
+ }
1095
+ console.log();
1096
+ break;
1097
+ }
1098
+ case 'show': {
1099
+ if (!opts.name) {
1100
+ out.error('Usage: claude-tempo ensemble show <name>');
1101
+ process.exit(1);
1102
+ }
1103
+ const content = (0, saver_1.readSavedBlueprint)(opts.name);
1104
+ if (!content) {
1105
+ out.error(`No saved ensemble named "${opts.name}"`);
1106
+ out.log(` Run ${out.dim('claude-tempo ensemble list')} to see available ensembles.`);
1107
+ process.exit(1);
1108
+ }
1109
+ console.log(content);
1110
+ break;
1111
+ }
1112
+ default:
1113
+ out.error('Usage: claude-tempo ensemble <save|list|show> [name]');
1114
+ out.log(`\n ${out.dim('claude-tempo ensemble save [name]')} Save current ensemble state`);
1115
+ out.log(` ${out.dim('claude-tempo ensemble list')} List saved ensembles`);
1116
+ out.log(` ${out.dim('claude-tempo ensemble show <name>')} Display a saved blueprint`);
1117
+ process.exit(1);
1118
+ }
1119
+ }
870
1120
  function help() {
871
1121
  console.log(`
872
1122
  ${out.bold('claude-tempo')} — Multi-session Claude Code coordination via Temporal
@@ -885,6 +1135,7 @@ ${out.bold('Commands:')}
885
1135
  ${out.cyan('start')} [ensemble] Start a player session
886
1136
  ${out.cyan('stop')} [ensemble] Stop sessions (-n <name> for one, or --all)
887
1137
  ${out.cyan('status')} [ensemble] Show active sessions and Temporal health
1138
+ ${out.cyan('ensemble')} <sub> Manage saved ensemble blueprints (save/list/show)
888
1139
  ${out.cyan('config')} Configure Temporal connection settings
889
1140
  ${out.cyan('init')} Register MCP server globally (or --project for .mcp.json)
890
1141
  ${out.cyan('preflight')} Run preflight checks only
@@ -905,6 +1156,7 @@ ${out.bold('Other options:')}
905
1156
  --project Use per-project .mcp.json instead of global (init only)
906
1157
  --keep-mcp Don't remove MCP config (down only)
907
1158
  --all Stop all sessions (stop only)
1159
+ --from <file> Load ensemble from a YAML blueprint (up only)
908
1160
  --ensemble <name> Target a specific ensemble (stop only)
909
1161
  -d, --dir <path> Target directory (default: cwd)
910
1162