claude-tempo 0.6.0 → 0.7.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/README.md CHANGED
@@ -91,7 +91,7 @@ claude-tempo <command> [options]
91
91
 
92
92
  | Command | Description |
93
93
  |---------|-------------|
94
- | `up [ensemble]` | First-time setup: start Temporal, configure MCP, launch conductor |
94
+ | `up [ensemble]` | First-time setup: start Temporal, configure MCP, launch conductor. Use `--from` to load a blueprint. |
95
95
  | `down` | Stop Temporal, terminate sessions, remove MCP config |
96
96
  | `server` | Start the Temporal dev server and register search attributes |
97
97
  | `conduct [ensemble]` | Start a conductor session (one per ensemble). Use `--resume` or `--replace` if one exists. |
@@ -101,6 +101,7 @@ claude-tempo <command> [options]
101
101
  | `stop [ensemble]` | Stop sessions (`-n <name>` for one, `--all` for everything) |
102
102
  | `init` | Register claude-tempo MCP server globally (`--project` for per-directory) |
103
103
  | `preflight` | Run environment checks |
104
+ | `ensemble <sub>` | Manage saved blueprints (`save`, `list`, `show`) |
104
105
  | `help` | Show usage info |
105
106
 
106
107
  ### Global options
@@ -115,6 +116,7 @@ claude-tempo <command> [options]
115
116
  --skip-preflight Skip preflight checks (start/conduct)
116
117
  -d, --dir <path> Target directory (default: cwd)
117
118
  --background Run Temporal in background (server only)
119
+ --from <file> Load an ensemble blueprint on startup (up only)
118
120
  --resume Resume an existing conductor session (conduct only)
119
121
  --replace Stop existing conductor and start fresh (conduct only)
120
122
  ```
@@ -212,6 +214,8 @@ These tools are available inside Claude Code sessions connected to claude-tempo:
212
214
  | `schedule` | Create a one-shot or recurring schedule to cue a player. |
213
215
  | `unschedule` | Cancel a named schedule. |
214
216
  | `schedules` | List all active schedules. |
217
+ | `save_ensemble` | Save the current ensemble as a YAML blueprint (conductor only). |
218
+ | `load_ensemble` | Load a blueprint to recruit players and create schedules. |
215
219
 
216
220
  ## Scheduling
217
221
 
@@ -244,6 +248,80 @@ Schedules support one-shot delays, fixed times, and recurring intervals with opt
244
248
  - `claude-tempo status` shows active schedules alongside sessions
245
249
  - A single durable scheduler workflow per ensemble manages all schedules using Temporal timers
246
250
 
251
+ ## Ensemble Blueprints
252
+
253
+ 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.
254
+
255
+ ### Example blueprint
256
+
257
+ ```yaml
258
+ name: my-project
259
+ conductor:
260
+ instructions: "Coordinate the frontend and backend teams"
261
+ players:
262
+ - name: frontend
263
+ workDir: /repos/my-app
264
+ instructions: "Build the React dashboard in src/components"
265
+ - name: backend
266
+ workDir: /repos/my-api
267
+ instructions: "Implement the REST endpoints in src/routes"
268
+ - name: ops
269
+ workDir: /repos/infra
270
+ agent: agents/ops-agent.md
271
+ instructions: "Monitor deployments and run health checks"
272
+ schedules:
273
+ - name: status-check
274
+ message: "Report your current progress and any blockers"
275
+ target: all
276
+ every: 30m
277
+ - name: deploy-reminder
278
+ message: "Check if the staging deploy succeeded"
279
+ target: ops
280
+ delay: 10m
281
+ ```
282
+
283
+ ### Three ways to use blueprints
284
+
285
+ 1. **From the CLI** — load a blueprint when starting an ensemble:
286
+
287
+ ```bash
288
+ claude-tempo up --from my-blueprint.yaml
289
+ ```
290
+
291
+ 2. **From inside a session** — use the `load_ensemble` tool:
292
+
293
+ *"Load the blueprint from ~/.claude-tempo/ensembles/my-project.yaml"*
294
+
295
+ 3. **Save the current state** — snapshot a running ensemble as a blueprint (conductor only):
296
+
297
+ *"Save this ensemble as a blueprint called my-project"*
298
+
299
+ ### Natural language examples
300
+
301
+ Tell your session things like:
302
+
303
+ - *"Load the my-project blueprint"*
304
+ - *"Save this ensemble as a blueprint"*
305
+ - *"Load the blueprint from /repos/configs/team.yaml"*
306
+
307
+ ### Fan-out schedules
308
+
309
+ 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:
310
+
311
+ - *"Schedule a message every 30 minutes to all players asking for a progress update"*
312
+
313
+ ### Custom agents
314
+
315
+ 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:
316
+
317
+ ```yaml
318
+ players:
319
+ - name: security-reviewer
320
+ workDir: /repos/my-app
321
+ agent: agents/security-review.md
322
+ instructions: "Review the latest PR for security issues"
323
+ ```
324
+
247
325
  ## Conductors
248
326
 
249
327
  A **conductor** is an optional special player that acts as an orchestration hub. Use one when you want:
@@ -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, '..', '..');
@@ -555,11 +559,18 @@ async function up(opts) {
555
559
  temporalEnvVars[config_1.ENV.TEMPORAL_TLS_CERT_PATH] = config.temporalTlsCertPath;
556
560
  if (config.temporalTlsKeyPath)
557
561
  temporalEnvVars[config_1.ENV.TEMPORAL_TLS_KEY_PATH] = config.temporalTlsKeyPath;
562
+ // Load blueprint if --from is provided
563
+ const blueprint = opts.from ? (0, loader_1.loadBlueprint)((0, path_1.resolve)(opts.from)) : undefined;
564
+ if (blueprint) {
565
+ out.check('Blueprint loaded', true, blueprint.name);
566
+ }
567
+ // Resolve conductor agent from blueprint or CLI flags
568
+ const conductorAgent = blueprint?.conductor?.agent === 'copilot' ? 'copilot' : opts.agent;
558
569
  // Step 5: Launch conductor
559
570
  console.log();
560
- out.log(`Launching conductor in ensemble ${out.cyan(opts.ensemble)}${opts.agent === 'copilot' ? out.dim(' (copilot)') : ''}...`);
571
+ out.log(`Launching conductor in ensemble ${out.cyan(opts.ensemble)}${conductorAgent === 'copilot' ? out.dim(' (copilot)') : ''}...`);
561
572
  let pid;
562
- if (opts.agent === 'copilot') {
573
+ if (conductorAgent === 'copilot') {
563
574
  ({ pid } = (0, spawn_1.spawnCopilotBridge)({
564
575
  name: opts.name || `${opts.ensemble}-conductor`,
565
576
  ensemble: opts.ensemble,
@@ -587,16 +598,199 @@ async function up(opts) {
587
598
  [config_1.ENV.PLAYER_NAME]: sessionName,
588
599
  }));
589
600
  }
601
+ out.success(`Conductor launched (pid ${pid ?? 'unknown'})`);
602
+ // Step 6: If blueprint provided, recruit players and create schedules
603
+ if (blueprint) {
604
+ // Connect to Temporal to send signals
605
+ const connection = await (0, connection_1.createTemporalConnection)(config);
606
+ const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
607
+ // Wait for conductor workflow to appear
608
+ out.log(`\n Waiting for conductor to register...`);
609
+ const conductorWfId = (0, config_1.conductorWorkflowId)(opts.ensemble);
610
+ let conductorReady = false;
611
+ for (let i = 0; i < 30; i++) {
612
+ await new Promise(r => setTimeout(r, 500));
613
+ try {
614
+ const handle = client.workflow.getHandle(conductorWfId);
615
+ const desc = await handle.describe();
616
+ if (desc.status.name === 'RUNNING') {
617
+ conductorReady = true;
618
+ break;
619
+ }
620
+ }
621
+ catch { /* not yet */ }
622
+ }
623
+ if (!conductorReady) {
624
+ out.warn('Conductor did not register within 15s — skipping blueprint players/schedules');
625
+ }
626
+ else {
627
+ out.check('Conductor registered', true);
628
+ // Send conductor instructions if provided
629
+ if (blueprint.conductor?.instructions) {
630
+ try {
631
+ const handle = client.workflow.getHandle(conductorWfId);
632
+ await handle.signal('receiveMessage', {
633
+ from: 'blueprint',
634
+ text: blueprint.conductor.instructions,
635
+ });
636
+ out.check('Conductor instructions sent', true);
637
+ }
638
+ catch (err) {
639
+ out.warn(`Could not send conductor instructions: ${err}`);
640
+ }
641
+ }
642
+ // Recruit players sequentially (each polls for ~15s)
643
+ if (blueprint.players.length > 0) {
644
+ console.log();
645
+ out.log(`Recruiting ${blueprint.players.length} player${blueprint.players.length !== 1 ? 's' : ''} from blueprint...`);
646
+ for (const player of blueprint.players) {
647
+ const playerAgent = player.agent === 'copilot' ? 'copilot' : 'claude';
648
+ const playerWorkDir = player.workDir || process.cwd();
649
+ // Record existing workflows to detect the new one
650
+ const existingIds = new Set();
651
+ const listQuery = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
652
+ for await (const wf of client.workflow.list({ query: listQuery })) {
653
+ existingIds.add(wf.workflowId);
654
+ }
655
+ // Spawn the player
656
+ if (playerAgent === 'copilot') {
657
+ (0, spawn_1.spawnCopilotBridge)({
658
+ name: player.name,
659
+ ensemble: opts.ensemble,
660
+ temporalAddress: config.temporalAddress,
661
+ temporalNamespace: config.temporalNamespace,
662
+ temporalApiKey: config.temporalApiKey,
663
+ temporalTlsCertPath: config.temporalTlsCertPath,
664
+ temporalTlsKeyPath: config.temporalTlsKeyPath,
665
+ isConductor: false,
666
+ workDir: playerWorkDir,
667
+ });
668
+ }
669
+ else {
670
+ const claudeArgs = [
671
+ '--dangerously-skip-permissions',
672
+ '--dangerously-load-development-channels', 'server:claude-tempo',
673
+ '-n', player.name,
674
+ ];
675
+ (0, spawn_1.spawnInTerminal)(claudeArgs, playerWorkDir, {
676
+ ...temporalEnvVars,
677
+ [config_1.ENV.ENSEMBLE]: opts.ensemble,
678
+ [config_1.ENV.CONDUCTOR]: '',
679
+ [config_1.ENV.PLAYER_NAME]: player.name,
680
+ });
681
+ }
682
+ // Poll for the new workflow to appear (up to ~15s)
683
+ let newWorkflowId = null;
684
+ for (let attempt = 0; attempt < 30; attempt++) {
685
+ await new Promise(r => setTimeout(r, 500));
686
+ for await (const wf of client.workflow.list({ query: listQuery })) {
687
+ if (!existingIds.has(wf.workflowId)) {
688
+ newWorkflowId = wf.workflowId;
689
+ break;
690
+ }
691
+ }
692
+ if (newWorkflowId)
693
+ break;
694
+ }
695
+ if (newWorkflowId && player.instructions) {
696
+ try {
697
+ const handle = client.workflow.getHandle(newWorkflowId);
698
+ await handle.signal('receiveMessage', {
699
+ from: 'blueprint',
700
+ text: player.instructions,
701
+ });
702
+ }
703
+ catch { /* best effort */ }
704
+ }
705
+ const status = newWorkflowId ? out.green('ok') : out.yellow('slow');
706
+ out.log(` ${status} ${out.bold(player.name)} in ${playerWorkDir}`);
707
+ }
708
+ }
709
+ // Create schedules
710
+ if (blueprint.schedules && blueprint.schedules.length > 0) {
711
+ console.log();
712
+ out.log(`Creating ${blueprint.schedules.length} schedule${blueprint.schedules.length !== 1 ? 's' : ''}...`);
713
+ for (const sched of blueprint.schedules) {
714
+ try {
715
+ const entry = blueprintScheduleToEntry(sched);
716
+ const schedulerWfId = (0, config_1.schedulerWorkflowId)(opts.ensemble);
717
+ const handle = client.workflow.getHandle(schedulerWfId);
718
+ await handle.signal(scheduler_signals_1.addScheduleSignal, entry);
719
+ out.check(sched.name, true, `→ ${sched.target}`);
720
+ }
721
+ catch (err) {
722
+ out.warn(`Could not create schedule "${sched.name}": ${err}`);
723
+ }
724
+ }
725
+ }
726
+ }
727
+ await connection.close();
728
+ }
590
729
  console.log();
591
730
  out.success('You\'re all set!');
592
- out.log(` Conductor launched (pid ${pid ?? 'unknown'})`);
593
731
  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`);
732
+ if (!blueprint) {
733
+ out.log(`\n ${out.bold('What next?')}`);
734
+ out.log(` ${out.dim('claude-tempo start ' + opts.ensemble)} Add a player session`);
735
+ out.log(` ${out.dim('claude-tempo status ' + opts.ensemble)} See who\'s active`);
736
+ out.log(` Or ask the conductor to ${out.dim('recruit')} players for you`);
737
+ }
738
+ else {
739
+ out.log(` Blueprint: ${out.dim(blueprint.name)}`);
740
+ out.log(` Players: ${blueprint.players.length}`);
741
+ if (blueprint.schedules?.length)
742
+ out.log(` Schedules: ${blueprint.schedules.length}`);
743
+ out.log(`\n ${out.dim('claude-tempo status ' + opts.ensemble)} See who\'s active`);
744
+ }
598
745
  console.log();
599
746
  }
747
+ /** Convert a blueprint schedule definition to a ScheduleEntry for the scheduler workflow. */
748
+ function blueprintScheduleToEntry(sched) {
749
+ const now = Date.now();
750
+ let nextFireAt;
751
+ let interval;
752
+ if (sched.every) {
753
+ interval = parseDuration(sched.every);
754
+ nextFireAt = sched.delay
755
+ ? new Date(now + parseDuration(sched.delay)).toISOString()
756
+ : new Date(now + interval).toISOString();
757
+ }
758
+ else if (sched.at) {
759
+ nextFireAt = new Date(sched.at).toISOString();
760
+ }
761
+ else if (sched.delay) {
762
+ nextFireAt = new Date(now + parseDuration(sched.delay)).toISOString();
763
+ }
764
+ else {
765
+ nextFireAt = new Date(now + 60_000).toISOString(); // default: 1 minute
766
+ }
767
+ return {
768
+ name: sched.name,
769
+ message: sched.message,
770
+ target: sched.target,
771
+ createdBy: 'blueprint',
772
+ nextFireAt,
773
+ interval,
774
+ until: sched.until,
775
+ remainingCount: sched.count,
776
+ firedCount: 0,
777
+ type: interval ? 'interval' : 'once',
778
+ };
779
+ }
780
+ /** Parse a human duration string like "10m", "1h", "30s" to milliseconds. */
781
+ function parseDuration(s) {
782
+ const match = s.match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)$/);
783
+ if (!match)
784
+ throw new Error(`Invalid duration: "${s}"`);
785
+ const value = parseFloat(match[1]);
786
+ switch (match[2]) {
787
+ case 's': return value * 1_000;
788
+ case 'm': return value * 60_000;
789
+ case 'h': return value * 3_600_000;
790
+ case 'd': return value * 86_400_000;
791
+ default: throw new Error(`Unknown duration unit: "${match[2]}"`);
792
+ }
793
+ }
600
794
  async function down(opts) {
601
795
  const config = (0, config_1.getConfig)(opts);
602
796
  out.heading('claude-tempo teardown');
@@ -867,6 +1061,57 @@ function killBridgeProcesses() {
867
1061
  // logs dir unreadable
868
1062
  }
869
1063
  }
1064
+ async function ensembleCommand(opts) {
1065
+ switch (opts.subcommand) {
1066
+ case 'save': {
1067
+ const config = (0, config_1.getConfig)(opts);
1068
+ const connection = await (0, connection_1.createTemporalConnection)(config);
1069
+ const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
1070
+ const ensemble = opts.name || config.ensemble;
1071
+ try {
1072
+ const path = await (0, saver_1.saveBlueprint)(client, ensemble);
1073
+ out.success(`Saved ensemble "${ensemble}" to ${path}`);
1074
+ }
1075
+ finally {
1076
+ await connection.close();
1077
+ }
1078
+ break;
1079
+ }
1080
+ case 'list': {
1081
+ const blueprints = (0, saver_1.listBlueprints)();
1082
+ if (blueprints.length === 0) {
1083
+ out.log('No saved ensembles. Use `claude-tempo ensemble save [name]` to save one.');
1084
+ return;
1085
+ }
1086
+ out.heading('Saved ensembles');
1087
+ for (const bp of blueprints) {
1088
+ out.log(` ${out.bold(bp.name)} ${out.dim(bp.path)}`);
1089
+ }
1090
+ console.log();
1091
+ break;
1092
+ }
1093
+ case 'show': {
1094
+ if (!opts.name) {
1095
+ out.error('Usage: claude-tempo ensemble show <name>');
1096
+ process.exit(1);
1097
+ }
1098
+ const content = (0, saver_1.readSavedBlueprint)(opts.name);
1099
+ if (!content) {
1100
+ out.error(`No saved ensemble named "${opts.name}"`);
1101
+ out.log(` Run ${out.dim('claude-tempo ensemble list')} to see available ensembles.`);
1102
+ process.exit(1);
1103
+ }
1104
+ console.log(content);
1105
+ break;
1106
+ }
1107
+ default:
1108
+ out.error('Usage: claude-tempo ensemble <save|list|show> [name]');
1109
+ out.log(`\n ${out.dim('claude-tempo ensemble save [name]')} Save current ensemble state`);
1110
+ out.log(` ${out.dim('claude-tempo ensemble list')} List saved ensembles`);
1111
+ out.log(` ${out.dim('claude-tempo ensemble show <name>')} Display a saved blueprint`);
1112
+ process.exit(1);
1113
+ }
1114
+ }
870
1115
  function help() {
871
1116
  console.log(`
872
1117
  ${out.bold('claude-tempo')} — Multi-session Claude Code coordination via Temporal
@@ -885,6 +1130,7 @@ ${out.bold('Commands:')}
885
1130
  ${out.cyan('start')} [ensemble] Start a player session
886
1131
  ${out.cyan('stop')} [ensemble] Stop sessions (-n <name> for one, or --all)
887
1132
  ${out.cyan('status')} [ensemble] Show active sessions and Temporal health
1133
+ ${out.cyan('ensemble')} <sub> Manage saved ensemble blueprints (save/list/show)
888
1134
  ${out.cyan('config')} Configure Temporal connection settings
889
1135
  ${out.cyan('init')} Register MCP server globally (or --project for .mcp.json)
890
1136
  ${out.cyan('preflight')} Run preflight checks only
@@ -905,6 +1151,7 @@ ${out.bold('Other options:')}
905
1151
  --project Use per-project .mcp.json instead of global (init only)
906
1152
  --keep-mcp Don't remove MCP config (down only)
907
1153
  --all Stop all sessions (stop only)
1154
+ --from <file> Load ensemble from a YAML blueprint (up only)
908
1155
  --ensemble <name> Target a specific ensemble (stop only)
909
1156
  -d, --dir <path> Target directory (default: cwd)
910
1157
 
package/dist/cli.js CHANGED
@@ -70,6 +70,9 @@ function parseArgs(argv) {
70
70
  else if (arg === '--temporal-tls-key' && i + 1 < argv.length) {
71
71
  result.temporalTlsKeyPath = argv[++i];
72
72
  }
73
+ else if (arg === '--from' && i + 1 < argv.length) {
74
+ result.from = argv[++i];
75
+ }
73
76
  else if ((arg === '-n' || arg === '--name') && i + 1 < argv.length) {
74
77
  result.name = argv[++i];
75
78
  }
@@ -201,10 +204,18 @@ async function main() {
201
204
  await (0, commands_1.up)({
202
205
  ensemble,
203
206
  name: args.name,
207
+ from: args.from,
204
208
  agent: resolvedAgent(),
205
209
  ...overrides,
206
210
  });
207
211
  break;
212
+ case 'ensemble':
213
+ await (0, commands_1.ensembleCommand)({
214
+ subcommand: args.positional[1],
215
+ name: args.positional[2],
216
+ ...overrides,
217
+ });
218
+ break;
208
219
  case 'init':
209
220
  await (0, commands_1.init)({ dir: args.dir, project: args.project });
210
221
  break;
@@ -0,0 +1,5 @@
1
+ import { EnsembleBlueprint } from './schema';
2
+ /**
3
+ * Load and validate an ensemble blueprint from a YAML file.
4
+ */
5
+ export declare function loadBlueprint(filePath: string): EnsembleBlueprint;
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loadBlueprint = loadBlueprint;
4
+ const fs_1 = require("fs");
5
+ const yaml_1 = require("yaml");
6
+ /**
7
+ * Load and validate an ensemble blueprint from a YAML file.
8
+ */
9
+ function loadBlueprint(filePath) {
10
+ const raw = (0, fs_1.readFileSync)(filePath, 'utf8');
11
+ const doc = (0, yaml_1.parse)(raw);
12
+ if (!doc || typeof doc !== 'object') {
13
+ throw new Error(`Invalid blueprint: file does not contain a YAML object`);
14
+ }
15
+ // Required: name
16
+ if (typeof doc.name !== 'string' || !doc.name) {
17
+ throw new Error(`Invalid blueprint: "name" is required and must be a non-empty string`);
18
+ }
19
+ // Required: players array
20
+ if (!Array.isArray(doc.players)) {
21
+ throw new Error(`Invalid blueprint: "players" must be an array`);
22
+ }
23
+ for (let i = 0; i < doc.players.length; i++) {
24
+ const p = doc.players[i];
25
+ if (typeof p.name !== 'string' || !p.name) {
26
+ throw new Error(`Invalid blueprint: players[${i}].name is required`);
27
+ }
28
+ if (!/^[a-zA-Z0-9_-]+$/.test(p.name)) {
29
+ throw new Error(`Invalid blueprint: players[${i}].name "${p.name}" contains invalid characters`);
30
+ }
31
+ }
32
+ // Validate schedules if present
33
+ if (doc.schedules != null) {
34
+ if (!Array.isArray(doc.schedules)) {
35
+ throw new Error(`Invalid blueprint: "schedules" must be an array`);
36
+ }
37
+ for (let i = 0; i < doc.schedules.length; i++) {
38
+ const s = doc.schedules[i];
39
+ if (typeof s.name !== 'string' || !s.name) {
40
+ throw new Error(`Invalid blueprint: schedules[${i}].name is required`);
41
+ }
42
+ if (typeof s.message !== 'string' || !s.message) {
43
+ throw new Error(`Invalid blueprint: schedules[${i}].message is required`);
44
+ }
45
+ if (typeof s.target !== 'string' || !s.target) {
46
+ throw new Error(`Invalid blueprint: schedules[${i}].target is required`);
47
+ }
48
+ if (!s.at && !s.delay && !s.every) {
49
+ throw new Error(`Invalid blueprint: schedules[${i}] must have at least one of: at, delay, every`);
50
+ }
51
+ }
52
+ }
53
+ return {
54
+ name: doc.name,
55
+ description: doc.description,
56
+ conductor: doc.conductor,
57
+ players: doc.players,
58
+ schedules: doc.schedules,
59
+ };
60
+ }
@@ -0,0 +1,17 @@
1
+ import { Client } from '@temporalio/client';
2
+ /**
3
+ * Save the current live ensemble state to a YAML blueprint file.
4
+ * Queries all running sessions and active schedules from Temporal.
5
+ */
6
+ export declare function saveBlueprint(client: Client, ensemble: string, filePath?: string): Promise<string>;
7
+ /**
8
+ * List all saved ensemble blueprints in ~/.claude-tempo/ensembles/.
9
+ */
10
+ export declare function listBlueprints(): Array<{
11
+ name: string;
12
+ path: string;
13
+ }>;
14
+ /**
15
+ * Read a saved blueprint by name from ~/.claude-tempo/ensembles/.
16
+ */
17
+ export declare function readSavedBlueprint(name: string): string | null;
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.saveBlueprint = saveBlueprint;
4
+ exports.listBlueprints = listBlueprints;
5
+ exports.readSavedBlueprint = readSavedBlueprint;
6
+ const fs_1 = require("fs");
7
+ const path_1 = require("path");
8
+ const yaml_1 = require("yaml");
9
+ const config_1 = require("../config");
10
+ const ENSEMBLES_DIR = (0, path_1.join)(config_1.CLAUDE_TEMPO_HOME, 'ensembles');
11
+ function ensemblesDir() {
12
+ (0, fs_1.mkdirSync)(ENSEMBLES_DIR, { recursive: true });
13
+ return ENSEMBLES_DIR;
14
+ }
15
+ /**
16
+ * Save the current live ensemble state to a YAML blueprint file.
17
+ * Queries all running sessions and active schedules from Temporal.
18
+ */
19
+ async function saveBlueprint(client, ensemble, filePath) {
20
+ const outputPath = filePath || (0, path_1.join)(ensemblesDir(), `${ensemble}.yaml`);
21
+ // Query all running session workflows
22
+ const query = 'WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"';
23
+ const players = [];
24
+ let conductor;
25
+ for await (const wf of client.workflow.list({ query })) {
26
+ try {
27
+ const handle = client.workflow.getHandle(wf.workflowId);
28
+ const [metadata, part] = await Promise.all([
29
+ handle.query('getMetadata').catch(() => ({})),
30
+ handle.query('getPart').catch(() => ''),
31
+ ]);
32
+ const meta = metadata;
33
+ if (meta.ensemble !== ensemble)
34
+ continue;
35
+ const isConductor = meta.isConductor || false;
36
+ const agentType = meta.agentType || 'claude';
37
+ const workDir = meta.workDir || undefined;
38
+ if (isConductor) {
39
+ conductor = {
40
+ agent: agentType === 'copilot' ? 'copilot' : undefined,
41
+ };
42
+ }
43
+ else {
44
+ const name = meta.playerId || wf.workflowId.split('-').pop() || 'unknown';
45
+ players.push({
46
+ name,
47
+ workDir,
48
+ agent: agentType === 'copilot' ? 'copilot' : undefined,
49
+ });
50
+ }
51
+ }
52
+ catch {
53
+ // workflow may have closed between list and query
54
+ }
55
+ }
56
+ // Query active schedules
57
+ const schedules = [];
58
+ try {
59
+ const schedulerWfId = (0, config_1.schedulerWorkflowId)(ensemble);
60
+ const handle = client.workflow.getHandle(schedulerWfId);
61
+ const entries = await handle.query('getSchedules');
62
+ for (const entry of entries) {
63
+ const sched = {
64
+ name: entry.name,
65
+ message: entry.message,
66
+ target: entry.target,
67
+ };
68
+ if (entry.interval) {
69
+ sched.every = formatDurationMs(entry.interval);
70
+ }
71
+ if (entry.until) {
72
+ sched.until = entry.until;
73
+ }
74
+ if (entry.remainingCount != null) {
75
+ sched.count = entry.remainingCount;
76
+ }
77
+ schedules.push(sched);
78
+ }
79
+ }
80
+ catch {
81
+ // No scheduler running or no schedules
82
+ }
83
+ const blueprint = {
84
+ name: ensemble,
85
+ conductor,
86
+ players,
87
+ ...(schedules.length > 0 ? { schedules } : {}),
88
+ };
89
+ // Ensure parent directory exists
90
+ const parentDir = outputPath.substring(0, outputPath.lastIndexOf('/') >= 0 ? outputPath.lastIndexOf('/') : outputPath.lastIndexOf('\\'));
91
+ if (parentDir)
92
+ (0, fs_1.mkdirSync)(parentDir, { recursive: true });
93
+ (0, fs_1.writeFileSync)(outputPath, (0, yaml_1.stringify)(blueprint));
94
+ return outputPath;
95
+ }
96
+ /**
97
+ * List all saved ensemble blueprints in ~/.claude-tempo/ensembles/.
98
+ */
99
+ function listBlueprints() {
100
+ const dir = ensemblesDir();
101
+ if (!(0, fs_1.existsSync)(dir))
102
+ return [];
103
+ return (0, fs_1.readdirSync)(dir)
104
+ .filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
105
+ .map(f => ({
106
+ name: f.replace(/\.ya?ml$/, ''),
107
+ path: (0, path_1.join)(dir, f),
108
+ }));
109
+ }
110
+ /**
111
+ * Read a saved blueprint by name from ~/.claude-tempo/ensembles/.
112
+ */
113
+ function readSavedBlueprint(name) {
114
+ const dir = ensemblesDir();
115
+ for (const ext of ['.yaml', '.yml']) {
116
+ const path = (0, path_1.join)(dir, name + ext);
117
+ if ((0, fs_1.existsSync)(path))
118
+ return (0, fs_1.readFileSync)(path, 'utf8');
119
+ }
120
+ return null;
121
+ }
122
+ function formatDurationMs(ms) {
123
+ if (ms >= 86_400_000 && ms % 86_400_000 === 0)
124
+ return `${ms / 86_400_000}d`;
125
+ if (ms >= 3_600_000 && ms % 3_600_000 === 0)
126
+ return `${ms / 3_600_000}h`;
127
+ if (ms >= 60_000 && ms % 60_000 === 0)
128
+ return `${ms / 60_000}m`;
129
+ return `${ms / 1000}s`;
130
+ }
@@ -0,0 +1,24 @@
1
+ export interface EnsembleBlueprint {
2
+ name: string;
3
+ description?: string;
4
+ conductor?: {
5
+ agent?: string;
6
+ instructions?: string;
7
+ };
8
+ players: Array<{
9
+ name: string;
10
+ workDir?: string;
11
+ agent?: string;
12
+ instructions?: string;
13
+ }>;
14
+ schedules?: Array<{
15
+ name: string;
16
+ message: string;
17
+ target: string;
18
+ at?: string;
19
+ delay?: string;
20
+ every?: string;
21
+ until?: string;
22
+ count?: number;
23
+ }>;
24
+ }
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ // Ensemble blueprint types — defines the structure of a saved/loaded ensemble configuration.
3
+ Object.defineProperty(exports, "__esModule", { value: true });
package/dist/server.js CHANGED
@@ -55,6 +55,8 @@ const set_name_1 = require("./tools/set-name");
55
55
  const schedule_1 = require("./tools/schedule");
56
56
  const unschedule_1 = require("./tools/unschedule");
57
57
  const schedules_1 = require("./tools/schedules");
58
+ const save_ensemble_1 = require("./tools/save-ensemble");
59
+ const load_ensemble_1 = require("./tools/load-ensemble");
58
60
  const channel_1 = require("./channel");
59
61
  const log = (...args) => console.error('[claude-tempo]', ...args);
60
62
  function getGitInfo(workDir) {
@@ -195,6 +197,8 @@ async function main() {
195
197
  (0, schedule_1.registerScheduleTool)(mcpServer, client, config, getPlayerId);
196
198
  (0, unschedule_1.registerUnscheduleTool)(mcpServer, client, config);
197
199
  (0, schedules_1.registerSchedulesTool)(mcpServer, client, config);
200
+ (0, save_ensemble_1.registerSaveEnsembleTool)(mcpServer, client, config, getPlayerId, isConductor);
201
+ (0, load_ensemble_1.registerLoadEnsembleTool)(mcpServer, client, config, getPlayerId, isBridgeMode ? 'copilot' : 'claude');
198
202
  const MAESTRO_ACK = '\n\n[IMPORTANT: This message is from a human (Maestro). Immediately cue the sender back with a brief acknowledgment and your planned next step before doing the work.]';
199
203
  // Start message poller — push messages into Claude Code via channel notifications.
200
204
  // Skip when running under the Copilot bridge: the bridge has its own poller that
@@ -0,0 +1,5 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { Client } from '@temporalio/client';
3
+ import { Config } from '../config';
4
+ import { AgentType } from '../types';
5
+ export declare function registerLoadEnsembleTool(server: McpServer, client: Client, config: Config, getPlayerId: () => string, ownAgentType?: AgentType): void;
@@ -0,0 +1,282 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerLoadEnsembleTool = registerLoadEnsembleTool;
4
+ const zod_1 = require("zod");
5
+ const path_1 = require("path");
6
+ const client_1 = require("@temporalio/client");
7
+ const config_1 = require("../config");
8
+ const loader_1 = require("../ensemble/loader");
9
+ const saver_1 = require("../ensemble/saver");
10
+ const resolve_1 = require("./resolve");
11
+ const spawn_1 = require("../spawn");
12
+ const helpers_1 = require("./helpers");
13
+ const log = (...args) => console.error('[claude-tempo:load-ensemble]', ...args);
14
+ function sleep(ms) {
15
+ return new Promise((resolve) => setTimeout(resolve, ms));
16
+ }
17
+ /** Parse a duration string like "30s", "10m", "2h", "1d" into milliseconds. */
18
+ function parseDuration(dur) {
19
+ const match = dur.match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)$/i);
20
+ if (!match)
21
+ return null;
22
+ const value = parseFloat(match[1]);
23
+ switch (match[2].toLowerCase()) {
24
+ case 's': return value * 1000;
25
+ case 'm': return value * 60_000;
26
+ case 'h': return value * 3_600_000;
27
+ case 'd': return value * 86_400_000;
28
+ default: return null;
29
+ }
30
+ }
31
+ function registerLoadEnsembleTool(server, client, config, getPlayerId, ownAgentType = 'claude') {
32
+ (0, helpers_1.defineTool)(server, 'load_ensemble', 'Load an ensemble blueprint — recruits players and creates schedules.', {
33
+ name: zod_1.z.string().optional().describe('Name of a saved blueprint (from ~/.claude-tempo/ensembles/)'),
34
+ path: zod_1.z.string().optional().describe('Explicit file path to a blueprint YAML file'),
35
+ }, async (args) => {
36
+ const blueprintName = args.name;
37
+ const blueprintPath = args.path;
38
+ if (!blueprintName && !blueprintPath) {
39
+ return {
40
+ content: [{
41
+ type: 'text',
42
+ text: 'Provide either `name` (saved blueprint) or `path` (file path). Exactly one is required.',
43
+ }],
44
+ isError: true,
45
+ };
46
+ }
47
+ if (blueprintName && blueprintPath) {
48
+ return {
49
+ content: [{
50
+ type: 'text',
51
+ text: 'Provide either `name` or `path`, not both.',
52
+ }],
53
+ isError: true,
54
+ };
55
+ }
56
+ try {
57
+ // Resolve the file path
58
+ let filePath;
59
+ if (blueprintPath) {
60
+ filePath = blueprintPath;
61
+ }
62
+ else {
63
+ // Try to find saved blueprint by name
64
+ const savedContent = (0, saver_1.readSavedBlueprint)(blueprintName);
65
+ if (!savedContent) {
66
+ return {
67
+ content: [{
68
+ type: 'text',
69
+ text: `No saved blueprint found with name "${blueprintName}". Check ~/.claude-tempo/ensembles/.`,
70
+ }],
71
+ isError: true,
72
+ };
73
+ }
74
+ // readSavedBlueprint returns content, but loadBlueprint wants a path.
75
+ // Construct the path directly.
76
+ const ensemblesDir = (0, path_1.join)(config_1.CLAUDE_TEMPO_HOME, 'ensembles');
77
+ // Try both extensions
78
+ const { existsSync } = require('fs');
79
+ filePath = (0, path_1.join)(ensemblesDir, `${blueprintName}.yaml`);
80
+ if (!existsSync(filePath)) {
81
+ filePath = (0, path_1.join)(ensemblesDir, `${blueprintName}.yml`);
82
+ }
83
+ }
84
+ const blueprint = (0, loader_1.loadBlueprint)(filePath);
85
+ const recruited = [];
86
+ const failed = [];
87
+ // Recruit players sequentially
88
+ for (const player of blueprint.players) {
89
+ const playerName = player.name;
90
+ const workDir = player.workDir || process.cwd();
91
+ const agentType = player.agent === 'copilot' ? 'copilot' : 'claude';
92
+ const isCustomAgent = player.agent && player.agent !== 'default' && player.agent !== 'copilot';
93
+ const systemPrompt = isCustomAgent ? player.agent : undefined;
94
+ // Skip if already active
95
+ const existing = await (0, resolve_1.resolveSession)(client, config.ensemble, playerName);
96
+ if (existing) {
97
+ log(`Player "${playerName}" already active — skipping recruit`);
98
+ recruited.push(`${playerName} (already active)`);
99
+ // Still send instructions if provided
100
+ if (player.instructions) {
101
+ try {
102
+ await existing.signal('receiveMessage', {
103
+ from: getPlayerId(),
104
+ text: player.instructions,
105
+ });
106
+ }
107
+ catch {
108
+ // best effort
109
+ }
110
+ }
111
+ continue;
112
+ }
113
+ // Record existing workflows to detect the new one
114
+ const existingIds = new Set();
115
+ const listQuery = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
116
+ for await (const wf of client.workflow.list({ query: listQuery })) {
117
+ existingIds.add(wf.workflowId);
118
+ }
119
+ // Spawn
120
+ try {
121
+ if (agentType === 'copilot') {
122
+ (0, spawn_1.spawnCopilotBridge)({
123
+ name: playerName,
124
+ ensemble: config.ensemble,
125
+ temporalAddress: config.temporalAddress,
126
+ temporalNamespace: config.temporalNamespace,
127
+ temporalApiKey: config.temporalApiKey,
128
+ temporalTlsCertPath: config.temporalTlsCertPath,
129
+ temporalTlsKeyPath: config.temporalTlsKeyPath,
130
+ isConductor: false,
131
+ workDir,
132
+ });
133
+ }
134
+ else {
135
+ const spawnArgs = [
136
+ '--dangerously-skip-permissions',
137
+ '--dangerously-load-development-channels', 'server:claude-tempo',
138
+ '-n', playerName,
139
+ ...(systemPrompt ? ['--system-prompt', systemPrompt] : []),
140
+ ];
141
+ const envVars = {
142
+ [config_1.ENV.ENSEMBLE]: config.ensemble,
143
+ [config_1.ENV.CONDUCTOR]: '',
144
+ [config_1.ENV.PLAYER_NAME]: playerName,
145
+ [config_1.ENV.TEMPORAL_ADDRESS]: config.temporalAddress,
146
+ [config_1.ENV.TEMPORAL_NAMESPACE]: config.temporalNamespace,
147
+ };
148
+ if (config.temporalApiKey)
149
+ envVars[config_1.ENV.TEMPORAL_API_KEY] = config.temporalApiKey;
150
+ if (config.temporalTlsCertPath)
151
+ envVars[config_1.ENV.TEMPORAL_TLS_CERT_PATH] = config.temporalTlsCertPath;
152
+ if (config.temporalTlsKeyPath)
153
+ envVars[config_1.ENV.TEMPORAL_TLS_KEY_PATH] = config.temporalTlsKeyPath;
154
+ (0, spawn_1.spawnInTerminal)(spawnArgs, workDir, envVars);
155
+ }
156
+ }
157
+ catch (err) {
158
+ failed.push(`${playerName}: spawn failed — ${err}`);
159
+ continue;
160
+ }
161
+ // Poll for the new workflow to appear (up to ~15s)
162
+ let newWorkflowId = null;
163
+ for (let attempt = 0; attempt < 30; attempt++) {
164
+ await sleep(500);
165
+ for await (const wf of client.workflow.list({ query: listQuery })) {
166
+ if (!existingIds.has(wf.workflowId)) {
167
+ newWorkflowId = wf.workflowId;
168
+ break;
169
+ }
170
+ }
171
+ if (newWorkflowId)
172
+ break;
173
+ }
174
+ if (!newWorkflowId) {
175
+ failed.push(`${playerName}: spawned but did not register within 15s`);
176
+ continue;
177
+ }
178
+ // Send initial instructions if provided
179
+ if (player.instructions) {
180
+ try {
181
+ const newHandle = client.workflow.getHandle(newWorkflowId);
182
+ await newHandle.signal('receiveMessage', {
183
+ from: getPlayerId(),
184
+ text: player.instructions,
185
+ });
186
+ }
187
+ catch {
188
+ // best effort
189
+ }
190
+ }
191
+ recruited.push(playerName);
192
+ log(`Recruited "${playerName}" in ${workDir}`);
193
+ }
194
+ // Create schedules
195
+ const schedulesCreated = [];
196
+ if (blueprint.schedules && blueprint.schedules.length > 0) {
197
+ for (const sched of blueprint.schedules) {
198
+ try {
199
+ const now = Date.now();
200
+ let nextFireAt;
201
+ let interval;
202
+ if (sched.at) {
203
+ nextFireAt = Date.parse(sched.at);
204
+ }
205
+ else if (sched.delay) {
206
+ const ms = parseDuration(sched.delay);
207
+ if (!ms)
208
+ throw new Error(`Invalid delay: ${sched.delay}`);
209
+ nextFireAt = now + ms;
210
+ }
211
+ else if (sched.every) {
212
+ const ms = parseDuration(sched.every);
213
+ if (!ms)
214
+ throw new Error(`Invalid interval: ${sched.every}`);
215
+ nextFireAt = now + ms;
216
+ interval = ms;
217
+ }
218
+ else {
219
+ throw new Error('No timing specified');
220
+ }
221
+ const scheduleEntry = {
222
+ name: sched.name,
223
+ message: sched.message,
224
+ target: sched.target,
225
+ type: sched.every ? 'interval' : 'once',
226
+ nextFireAt: new Date(nextFireAt).toISOString(),
227
+ interval,
228
+ until: sched.until,
229
+ remainingCount: sched.count,
230
+ firedCount: 0,
231
+ createdBy: getPlayerId(),
232
+ };
233
+ const wfId = (0, config_1.schedulerWorkflowId)(config.ensemble);
234
+ try {
235
+ const handle = client.workflow.getHandle(wfId);
236
+ await handle.describe();
237
+ await handle.signal('addSchedule', scheduleEntry);
238
+ }
239
+ catch {
240
+ await client.workflow.start('claudeSchedulerWorkflow', {
241
+ workflowId: wfId,
242
+ taskQueue: config.taskQueue,
243
+ args: [{ ensemble: config.ensemble, entries: [scheduleEntry] }],
244
+ workflowIdConflictPolicy: client_1.WorkflowIdConflictPolicy.USE_EXISTING,
245
+ searchAttributes: {
246
+ ClaudeTempoEnsemble: [config.ensemble],
247
+ },
248
+ });
249
+ }
250
+ schedulesCreated.push(sched.name);
251
+ }
252
+ catch (err) {
253
+ failed.push(`schedule "${sched.name}": ${err}`);
254
+ }
255
+ }
256
+ }
257
+ // Build summary
258
+ const lines = [`Loaded blueprint **${blueprint.name}**.`];
259
+ if (recruited.length > 0) {
260
+ lines.push(`Recruited: ${recruited.join(', ')}`);
261
+ }
262
+ if (schedulesCreated.length > 0) {
263
+ lines.push(`Schedules created: ${schedulesCreated.join(', ')}`);
264
+ }
265
+ if (failed.length > 0) {
266
+ lines.push(`Failures:\n${failed.map(f => ` - ${f}`).join('\n')}`);
267
+ }
268
+ return {
269
+ content: [{
270
+ type: 'text',
271
+ text: lines.join('\n'),
272
+ }],
273
+ };
274
+ }
275
+ catch (err) {
276
+ return {
277
+ content: [{ type: 'text', text: `Failed to load blueprint: ${err}` }],
278
+ isError: true,
279
+ };
280
+ }
281
+ });
282
+ }
@@ -20,10 +20,13 @@ function registerRecruitTool(server, client, config, getPlayerId, ownAgentType =
20
20
  .describe('Optional task or message for the new session (sent after it sets its name)'),
21
21
  agent: zod_1.z.enum(['claude', 'copilot']).optional()
22
22
  .describe(`Which agent to use (default: "${ownAgentType}", same as this session)`),
23
+ systemPrompt: zod_1.z.string().optional()
24
+ .describe('Path to a .md file to use as custom agent system prompt (--system-prompt)'),
23
25
  }, async (args) => {
24
26
  const { workDir, name, initialMessage } = args;
25
27
  const isConductor = args.conductor === true;
26
28
  const agent = args.agent || ownAgentType;
29
+ const systemPrompt = args.systemPrompt;
27
30
  // Validate name
28
31
  if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
29
32
  return {
@@ -101,6 +104,7 @@ function registerRecruitTool(server, client, config, getPlayerId, ownAgentType =
101
104
  '--dangerously-skip-permissions',
102
105
  '--dangerously-load-development-channels', 'server:claude-tempo',
103
106
  '-n', name,
107
+ ...(systemPrompt ? ['--system-prompt', systemPrompt] : []),
104
108
  ];
105
109
  const envVars = {
106
110
  [config_1.ENV.ENSEMBLE]: config.ensemble,
@@ -0,0 +1,4 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { Client } from '@temporalio/client';
3
+ import { Config } from '../config';
4
+ export declare function registerSaveEnsembleTool(server: McpServer, client: Client, config: Config, getPlayerId: () => string, isConductor: boolean): void;
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerSaveEnsembleTool = registerSaveEnsembleTool;
4
+ const zod_1 = require("zod");
5
+ const saver_1 = require("../ensemble/saver");
6
+ const helpers_1 = require("./helpers");
7
+ const log = (...args) => console.error('[claude-tempo:save-ensemble]', ...args);
8
+ function registerSaveEnsembleTool(server, client, config, getPlayerId, isConductor) {
9
+ (0, helpers_1.defineTool)(server, 'save_ensemble', 'Save the current ensemble state as a YAML blueprint. Only available to the conductor.', {
10
+ name: zod_1.z.string().optional().describe('Blueprint name (defaults to ensemble name)'),
11
+ path: zod_1.z.string().optional().describe('Explicit file path to save to'),
12
+ }, async (args) => {
13
+ if (!isConductor) {
14
+ return {
15
+ content: [{
16
+ type: 'text',
17
+ text: 'Only the conductor can save ensemble blueprints.',
18
+ }],
19
+ isError: true,
20
+ };
21
+ }
22
+ const blueprintName = args.name;
23
+ const filePath = args.path;
24
+ try {
25
+ // If a custom name is provided but no path, we don't override —
26
+ // saveBlueprint uses the ensemble name for the default path.
27
+ // We pass filePath through directly.
28
+ const outputPath = await (0, saver_1.saveBlueprint)(client, config.ensemble, filePath);
29
+ log(`Saved blueprint to ${outputPath}`);
30
+ return {
31
+ content: [{
32
+ type: 'text',
33
+ text: `Ensemble blueprint saved to **${outputPath}**.`,
34
+ }],
35
+ };
36
+ }
37
+ catch (err) {
38
+ return {
39
+ content: [{ type: 'text', text: `Failed to save ensemble: ${err}` }],
40
+ isError: true,
41
+ };
42
+ }
43
+ });
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-tempo",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "MCP server for multi-session Claude Code coordination via Temporal",
5
5
  "repository": {
6
6
  "type": "git",
@@ -53,6 +53,7 @@
53
53
  "@temporalio/client": "~1.15.0",
54
54
  "@temporalio/worker": "~1.15.0",
55
55
  "@temporalio/workflow": "~1.15.0",
56
+ "yaml": "^2.8.3",
56
57
  "zod": "^3.25.76"
57
58
  },
58
59
  "devDependencies": {