@sublang/playbook 7.0.0 → 8.0.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 (61) hide show
  1. package/README.md +17 -4
  2. package/docs/cli.md +74 -29
  3. package/docs/configuration.md +209 -112
  4. package/docs/embedding.md +71 -25
  5. package/package.json +4 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
  8. package/reference/sdlc/code.md +1 -1
  9. package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
  10. package/reference/sdlc/code.playbook/bin/launch-config.js +1078 -116
  11. package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
  12. package/reference/sdlc/code.playbook/bin/run.js +283 -298
  13. package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.js +7 -11
  18. package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
  19. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  21. package/reference/sdlc/code.playbook/code.playbook.js +12 -13
  22. package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  24. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  25. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
  27. package/reference/sdlc/code.playbook/playbook-captain.js +970 -289
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1403 -396
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
  30. package/reference/sdlc/decide.md +4 -4
  31. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
  32. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  33. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  34. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  35. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
  36. package/reference/sdlc/decide.playbook/decide.playbook.js +171 -134
  37. package/reference/sdlc/decide.playbook/decide.playbook.ts +238 -162
  38. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  39. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  40. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  41. package/reference/sdlc/review.md +4 -5
  42. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  43. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  44. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  45. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  46. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  47. package/reference/sdlc/review.playbook/review.playbook.js +16 -21
  48. package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
  49. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  50. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  51. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  52. package/slc/gears2fsm.md +27 -23
  53. package/slc/link.md +113 -93
  54. package/slc/text2gears.md +19 -18
  55. package/src/runtime.d.ts +20 -16
  56. package/src/runtime.ts +19 -23
  57. package/src/xstate-playbook-runtime.d.ts +21 -17
  58. package/src/xstate-playbook-runtime.js +241 -149
  59. package/src/xstate-playbook-runtime.ts +331 -178
  60. package/src/xstate-runtime.js +63 -24
  61. package/src/xstate-runtime.ts +96 -28
@@ -18,6 +18,7 @@ import {
18
18
  import { homedir, tmpdir } from 'node:os';
19
19
  import { dirname, isAbsolute, join, resolve } from 'node:path';
20
20
  import { fileURLToPath, pathToFileURL } from 'node:url';
21
+ import { isDeepStrictEqual } from 'node:util';
21
22
  import {
22
23
  parse as parseYaml,
23
24
  parseDocument as parseYamlDocument,
@@ -35,9 +36,10 @@ const DEFAULT_TEMPLATE_PATH = resolve(
35
36
  // PBCLI-1/8: the tmux projection uses the Playbook Captain shell adapter.
36
37
  export const PLAYBOOK_CAPTAIN_MODULE =
37
38
  '@sublang/playbook/playbook-captain';
38
- const PLAYBOOK_LAUNCHER_KEYS = ['from', 'command', 'players'];
39
+ const PLAYBOOK_LAUNCHER_KEYS = ['from', 'command', 'roles'];
39
40
  const PLAYBOOK_TOP_LEVEL_KEYS = new Set([
40
41
  'captain',
42
+ 'players',
41
43
  'playbooks',
42
44
  'layout',
43
45
  'notifications',
@@ -45,6 +47,8 @@ const PLAYBOOK_TOP_LEVEL_KEYS = new Set([
45
47
  ]);
46
48
  const RESERVED_CAPTAIN_PLAYBOOK_ID = 'captain';
47
49
  const RESERVED_CAPTAIN_ROLE_ID = 'captain';
50
+ const PLAYER_ID_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
51
+ const ROLE_ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
48
52
 
49
53
  // PBCLI-26: split ordered `--with <path>` pairs out of an argument vector.
50
54
  // The returned arrays are new values; the caller's vector is never changed.
@@ -110,6 +114,18 @@ export function mergeConfigs(base, overlay) {
110
114
  ]);
111
115
  }
112
116
 
117
+ // PBCLI-22/46: ordinary reopen must merge only durable catalog members.
118
+ // A selected member may first appear in an overlay, so each layer is pruned
119
+ // permissively before merge and the final strict projection below owns the
120
+ // missing-member diagnostic. Unselected accessors are never observed.
121
+ export function mergeSelectedConfigs(base, overlay, selectedMembers) {
122
+ const selected = validateSelectedMembers(selectedMembers);
123
+ return mergeConfigs(
124
+ projectSelectedLayer(base, selected, 'config'),
125
+ projectSelectedLayer(overlay, selected, 'overlay'),
126
+ );
127
+ }
128
+
113
129
  export function resolveConfigHome(env = process.env, home = homedir()) {
114
130
  return env.XDG_CONFIG_HOME || join(home, '.config');
115
131
  }
@@ -145,9 +161,14 @@ export async function loadLaunchPlan({
145
161
  prepareRegistryModule,
146
162
  templatePath = DEFAULT_TEMPLATE_PATH,
147
163
  onNotice = () => {},
164
+ selectedMembers,
148
165
  }) {
149
166
  seedUserConfigIfMissing(userConfigPath, templatePath, onNotice);
150
- migrateUserConfigIfRetired(userConfigPath, onNotice);
167
+ // PBCLI-22/46: a reopen must not rewrite, validate, or otherwise inspect
168
+ // config members outside the stored projection.
169
+ if (selectedMembers === undefined) {
170
+ migrateUserConfigIfRetired(userConfigPath, onNotice);
171
+ }
151
172
 
152
173
  let top = parseYaml(readFileSync(userConfigPath, 'utf8')) ?? {};
153
174
  if (overlayPaths.length > 0 && !isObject(top)) {
@@ -156,15 +177,328 @@ export async function loadLaunchPlan({
156
177
  );
157
178
  }
158
179
  for (const overlayPath of overlayPaths) {
159
- top = mergeConfigs(top, loadOverlayFragment(overlayPath));
180
+ const overlay = loadOverlayFragment(overlayPath);
181
+ top =
182
+ selectedMembers === undefined
183
+ ? mergeConfigs(top, overlay)
184
+ : mergeSelectedConfigs(top, overlay, selectedMembers);
160
185
  }
161
186
  return await normalizeLaunchPlan(top, {
162
187
  loadModule,
163
188
  configPath: userConfigPath,
164
189
  prepareRegistryModule,
190
+ selectedMembers,
191
+ });
192
+ }
193
+
194
+ // PBCLI-22/49: an interactive selected launch may project current tuning and
195
+ // presentation before its pane child owns the writer lease, but it must not
196
+ // prepare or import a registry from an unlocked advisory read. Rebuild the
197
+ // durable catalog from the validated stored structural projection and use the
198
+ // current config only for compatible Captain/player tuning and presentation.
199
+ export async function loadSelectedLaunchPlanDataOnly({
200
+ userConfigPath,
201
+ overlayPaths = [],
202
+ structuralProjection,
203
+ templatePath = DEFAULT_TEMPLATE_PATH,
204
+ onNotice = () => {},
205
+ }) {
206
+ const stored = validateStoredStructuralProjection(structuralProjection);
207
+ const selectedMembers = selectedMembersFromStoredStructure(stored);
208
+ seedUserConfigIfMissing(userConfigPath, templatePath, onNotice);
209
+
210
+ let top = parseYaml(readFileSync(userConfigPath, 'utf8')) ?? {};
211
+ if (overlayPaths.length > 0 && !isObject(top)) {
212
+ throw new Error(
213
+ `the top-level config at ${userConfigPath} must be a YAML map before --with can overlay it`,
214
+ );
215
+ }
216
+ top = projectSelectedLayer(
217
+ top,
218
+ validateSelectedMembers(selectedMembers),
219
+ 'config',
220
+ );
221
+ for (const overlayPath of overlayPaths) {
222
+ top = mergeSelectedConfigs(
223
+ top,
224
+ loadOverlayFragment(overlayPath),
225
+ selectedMembers,
226
+ );
227
+ }
228
+ return await normalizeSelectedLaunchPlanDataOnly(top, {
229
+ configPath: userConfigPath,
230
+ stored,
231
+ selectedMembers,
165
232
  });
166
233
  }
167
234
 
235
+ export async function normalizeSelectedLaunchPlanDataOnly(
236
+ top,
237
+ { configPath, stored, selectedMembers } = {},
238
+ ) {
239
+ stored = validateStoredStructuralProjection(stored);
240
+ const expectedMembers = selectedMembersFromStoredStructure(stored);
241
+ if (selectedMembers !== undefined) {
242
+ const supplied = validateSelectedMembers(selectedMembers);
243
+ if (
244
+ !isDeepStrictEqual(supplied.playbookIds, expectedMembers.playbookIds) ||
245
+ !isDeepStrictEqual(supplied.playerIds, expectedMembers.playerIds)
246
+ ) {
247
+ throw new Error(
248
+ 'selected launch members do not match the stored structural projection',
249
+ );
250
+ }
251
+ }
252
+ top = projectSelectedMembers(top, expectedMembers);
253
+ top = cloneJson(top, 'config');
254
+ assertNoRetiredProfiles(top, configPath);
255
+ if (hasOwn(top, 'run')) {
256
+ throw new Error(
257
+ 'top-level "run" was removed: configure the shared Captain under ' +
258
+ 'captain, top-level players, and playbooks.<id>.roles instead',
259
+ );
260
+ }
261
+ const unknownTopLevel = Object.keys(top).filter(
262
+ (key) => !PLAYBOOK_TOP_LEVEL_KEYS.has(key),
263
+ );
264
+ if (unknownTopLevel.length > 0) {
265
+ throw new Error(
266
+ `config has unknown top-level ${formatKeyList(unknownTopLevel)}`,
267
+ );
268
+ }
269
+ if (top.layout !== undefined && !isObject(top.layout)) {
270
+ throw new Error('layout must be a map');
271
+ }
272
+
273
+ const playersCfg = requireObject(top.players, 'players');
274
+ const configuredAgents = new Map();
275
+ for (const playerId of expectedMembers.playerIds) {
276
+ assertPlayerId(playerId, `players.${playerId}`);
277
+ const agent = resolveAgent(playersCfg[playerId], `players.${playerId}`, [
278
+ 'id',
279
+ ]);
280
+ configuredAgents.set(playerId, agent);
281
+ }
282
+ let captain = resolveAgent(top.captain, 'captain', ['from', 'options']);
283
+
284
+ const playbooksCfg = requireObject(top.playbooks, 'playbooks');
285
+ const tuningChecks = [];
286
+ let tuningCheckIndex = 0;
287
+ const authored = new Map();
288
+ for (const id of expectedMembers.playbookIds) {
289
+ const storedItem = stored.catalog[id];
290
+ const block = requireObject(playbooksCfg[id], `playbooks.${id}`);
291
+ if (hasOwn(block, 'players')) {
292
+ throw legacyPlayersError(`playbooks.${id}.players`, configPath);
293
+ }
294
+ if (
295
+ typeof block.from !== 'string' ||
296
+ block.from.trim().length === 0 ||
297
+ block.from !== block.from.trim()
298
+ ) {
299
+ throw new Error(
300
+ `playbooks.${id}.from must be a canonical trimmed module specifier`,
301
+ );
302
+ }
303
+ const configuredFrom = canonicalizeRegistrySpecifier(block.from, configPath);
304
+ if (configuredFrom !== storedItem.from) {
305
+ throw new Error(
306
+ `playbooks.${id}.from changed from the stored structural projection`,
307
+ );
308
+ }
309
+ const command = block.command ?? storedItem.manifestCommand;
310
+ if (command !== storedItem.command) {
311
+ throw new Error(
312
+ `playbooks.${id}.command changed from the stored structural projection`,
313
+ );
314
+ }
315
+ const rolesMap = requireObject(block.roles, `playbooks.${id}.roles`);
316
+ assertExactRoleBindings(
317
+ id,
318
+ Object.keys(rolesMap),
319
+ storedItem.requiredRoleIds,
320
+ );
321
+ const bindings = {};
322
+ for (const role of storedItem.requiredRoleIds) {
323
+ const binding = resolveRoleBinding(
324
+ rolesMap[role],
325
+ `playbooks.${id}.roles.${role}`,
326
+ );
327
+ if (binding.playerId !== storedItem.roles[role].playerId) {
328
+ throw new Error(
329
+ `playbooks.${id}.roles.${role} changed its stored player binding`,
330
+ );
331
+ }
332
+ bindings[role] = binding;
333
+ if (binding.model !== undefined || binding.effort !== undefined) {
334
+ let checkId;
335
+ do {
336
+ checkId = `binding-check-${tuningCheckIndex}`;
337
+ tuningCheckIndex += 1;
338
+ } while (configuredAgents.has(checkId));
339
+ tuningChecks.push({
340
+ id: checkId,
341
+ agent: applyTuningOverrides(
342
+ configuredAgents.get(binding.playerId),
343
+ binding,
344
+ ),
345
+ });
346
+ }
347
+ }
348
+ const optionSlice = Object.fromEntries(
349
+ Object.entries(block).filter(
350
+ ([key]) => !PLAYBOOK_LAUNCHER_KEYS.includes(key),
351
+ ),
352
+ );
353
+ if (!isDeepStrictEqual(optionSlice, storedItem.options)) {
354
+ throw new Error(
355
+ `playbooks.${id} options changed from the stored structural projection`,
356
+ );
357
+ }
358
+ authored.set(id, { bindings });
359
+ }
360
+
361
+ const firstRoleful = expectedMembers.playbookIds
362
+ .map((id) => stored.catalog[id])
363
+ .find((item) => item.requiredRoleIds.length > 0);
364
+ const initialVisible =
365
+ firstRoleful === undefined
366
+ ? []
367
+ : distinct(
368
+ firstRoleful.requiredRoleIds.map(
369
+ (role) => firstRoleful.roles[role].playerId,
370
+ ),
371
+ );
372
+ const validationVisible =
373
+ initialVisible.length === 0
374
+ ? expectedMembers.playerIds.slice(0, 1)
375
+ : initialVisible;
376
+ const provisional = {
377
+ captain: { ...captain, from: PLAYBOOK_CAPTAIN_MODULE, options: {} },
378
+ players: [
379
+ ...expectedMembers.playerIds.map((id) => ({
380
+ id,
381
+ ...configuredAgents.get(id),
382
+ })),
383
+ ...tuningChecks.map(({ id, agent }) => ({ id, ...agent })),
384
+ ],
385
+ layout: {
386
+ ...(isObject(top.layout) ? top.layout : {}),
387
+ initialVisible: validationVisible,
388
+ },
389
+ ...(top.notifications === undefined
390
+ ? {}
391
+ : { notifications: top.notifications }),
392
+ ...(top.theme === undefined ? {} : { theme: top.theme }),
393
+ };
394
+ const normalizedHost = await normalizeHostConfig(provisional);
395
+ const normalizedPresentationHost =
396
+ initialVisible.length === 0 && expectedMembers.playerIds.length > 0
397
+ ? await normalizeHostConfig({
398
+ ...provisional,
399
+ players: [],
400
+ layout: { ...provisional.layout, initialVisible: [] },
401
+ })
402
+ : normalizedHost;
403
+ const { from: _captainFrom, options: _captainOptions, ...normalizedCaptain } =
404
+ normalizedHost.captain;
405
+ captain = sessionAgentFromHostAgent(normalizedCaptain, 'captain');
406
+ const hostAgents = new Map(
407
+ normalizedHost.players.map(({ id, ...agent }) => [id, agent]),
408
+ );
409
+ const normalizedPlayerAgents = new Map(
410
+ expectedMembers.playerIds.map((id) => [
411
+ id,
412
+ sessionAgentFromHostAgent(hostAgents.get(id), `players.${id}`),
413
+ ]),
414
+ );
415
+
416
+ const catalog = Object.fromEntries(
417
+ expectedMembers.playbookIds.map((id) => {
418
+ const storedItem = stored.catalog[id];
419
+ const bindings = authored.get(id).bindings;
420
+ return [
421
+ id,
422
+ {
423
+ ...cloneJson(storedItem, `stored catalog.${id}`),
424
+ roles: Object.fromEntries(
425
+ storedItem.requiredRoleIds.map((role) => {
426
+ const binding = bindings[role];
427
+ const agent = normalizedPlayerAgents.get(binding.playerId);
428
+ return [
429
+ role,
430
+ {
431
+ playerId: binding.playerId,
432
+ model:
433
+ binding.model === undefined
434
+ ? agent.model
435
+ : overrideTuningSelection(binding.model),
436
+ effort:
437
+ binding.effort === undefined
438
+ ? agent.effort
439
+ : overrideTuningSelection(binding.effort),
440
+ },
441
+ ];
442
+ }),
443
+ ),
444
+ },
445
+ ];
446
+ }),
447
+ );
448
+ const candidateStructure = {
449
+ schemaVersion: stored.schemaVersion,
450
+ captain: fixedAgentProjection(captain),
451
+ players: expectedMembers.playerIds.map((id) => ({
452
+ id,
453
+ ...fixedAgentProjection(normalizedPlayerAgents.get(id)),
454
+ })),
455
+ catalog: Object.fromEntries(
456
+ Object.entries(catalog).map(([id, item]) => [
457
+ id,
458
+ {
459
+ ...cloneJson(stored.catalog[id], `stored catalog.${id}`),
460
+ roles: Object.fromEntries(
461
+ Object.entries(item.roles).map(([role, binding]) => [
462
+ role,
463
+ { playerId: binding.playerId },
464
+ ]),
465
+ ),
466
+ },
467
+ ]),
468
+ ),
469
+ };
470
+ if (!isDeepStrictEqual(candidateStructure, stored)) {
471
+ throw new Error(
472
+ 'current selected config does not reproduce the stored structural projection',
473
+ );
474
+ }
475
+
476
+ return deepFreeze(
477
+ cloneJson(
478
+ {
479
+ schemaVersion: 1,
480
+ captain,
481
+ players: expectedMembers.playerIds.map((id) => ({
482
+ id,
483
+ agent: normalizedPlayerAgents.get(id),
484
+ })),
485
+ catalog,
486
+ presentation: {
487
+ layout: {
488
+ ...normalizedPresentationHost.layout,
489
+ initialVisible,
490
+ },
491
+ notifications: normalizedPresentationHost.notifications,
492
+ ...(normalizedPresentationHost.theme === undefined
493
+ ? {}
494
+ : { theme: normalizedPresentationHost.theme }),
495
+ },
496
+ },
497
+ 'selected launch config',
498
+ ),
499
+ );
500
+ }
501
+
168
502
  // PBCLI-8 (DR-021): scalar agents are adapter shorthands and full blocks
169
503
  // carry their own settings without profile indirection.
170
504
  export function resolveAgent(value, path, reservedKeys = []) {
@@ -190,15 +524,16 @@ export function resolveAgent(value, path, reservedKeys = []) {
190
524
  // consulted for validation and then discarded.
191
525
  export async function normalizeLaunchPlan(
192
526
  top,
193
- { loadModule, configPath, prepareRegistryModule } = {},
527
+ { loadModule, configPath, prepareRegistryModule, selectedMembers } = {},
194
528
  ) {
195
529
  const importModule = loadModule ?? ((specifier) => import(specifier));
530
+ top = projectSelectedMembers(top, selectedMembers);
196
531
  top = cloneJson(top, 'config');
197
532
  assertNoRetiredProfiles(top, configPath);
198
533
  if (hasOwn(top, 'run')) {
199
534
  throw new Error(
200
535
  'top-level "run" was removed: configure the shared Captain under ' +
201
- 'captain and playbooks.<id>.players instead',
536
+ 'captain, top-level players, and playbooks.<id>.roles instead',
202
537
  );
203
538
  }
204
539
  const unknownTopLevel = Object.keys(top).filter(
@@ -213,13 +548,30 @@ export async function normalizeLaunchPlan(
213
548
  throw new Error('layout must be a map');
214
549
  }
215
550
 
551
+ const playersCfg = requireObject(top.players, 'players');
552
+ const allPlayerIds = Object.keys(playersCfg);
553
+ const configuredAgents = new Map();
554
+ for (const playerId of allPlayerIds) {
555
+ assertPlayerId(playerId, `players.${playerId}`);
556
+ const agent = resolveAgent(playersCfg[playerId], `players.${playerId}`, [
557
+ 'id',
558
+ ]);
559
+ if (
560
+ typeof agent.adapter !== 'string' ||
561
+ agent.adapter.trim().length === 0
562
+ ) {
563
+ throw new Error(`players.${playerId} must resolve an adapter`);
564
+ }
565
+ configuredAgents.set(playerId, agent);
566
+ }
567
+
216
568
  const playbooksCfg = requireObject(top.playbooks, 'playbooks');
217
569
  const ids = Object.keys(playbooksCfg);
218
570
  if (ids.length === 0) {
219
571
  throw new Error('playbooks must enable at least one playbook');
220
572
  }
221
- if (ids.some((id) => id.trim().length === 0)) {
222
- throw new Error('playbooks keys must be nonblank ids');
573
+ if (ids.some((id) => id.trim().length === 0 || id !== id.trim())) {
574
+ throw new Error('playbooks keys must be canonical trimmed nonblank ids');
223
575
  }
224
576
 
225
577
  let captain = resolveAgent(top.captain, 'captain', ['from', 'options']);
@@ -230,10 +582,11 @@ export async function normalizeLaunchPlan(
230
582
  throw new Error('captain must resolve an adapter');
231
583
  }
232
584
 
233
- // Validate and detach every config-owned value before provisioning or
234
- // importing any registry. That keeps malformed config side-effect free.
585
+ // Validate and detach every retained config-owned value before provisioning
586
+ // or importing any registry. That keeps malformed config side-effect free.
235
587
  const configuredPlaybooks = [];
236
- const seenHostIds = new Set();
588
+ const tuningChecks = [];
589
+ let tuningCheckIndex = 0;
237
590
  for (const id of ids) {
238
591
  if (id === RESERVED_CAPTAIN_PLAYBOOK_ID) {
239
592
  throw new Error(
@@ -241,66 +594,72 @@ export async function normalizeLaunchPlan(
241
594
  );
242
595
  }
243
596
  const block = requireObject(playbooksCfg[id], `playbooks.${id}`);
597
+ if (hasOwn(block, 'players')) {
598
+ throw legacyPlayersError(`playbooks.${id}.players`, configPath);
599
+ }
244
600
  const from = block.from;
245
- if (typeof from !== 'string' || from.trim().length === 0) {
246
- throw new Error(`playbooks.${id}.from must be a module specifier`);
601
+ if (
602
+ typeof from !== 'string' ||
603
+ from.trim().length === 0 ||
604
+ from !== from.trim()
605
+ ) {
606
+ throw new Error(
607
+ `playbooks.${id}.from must be a canonical trimmed module specifier`,
608
+ );
247
609
  }
248
610
  if (
249
611
  block.command !== undefined &&
250
- (typeof block.command !== 'string' || block.command.trim().length === 0)
612
+ (typeof block.command !== 'string' ||
613
+ block.command.trim().length === 0 ||
614
+ block.command !== block.command.trim())
251
615
  ) {
252
- throw new Error(`playbooks.${id}.command must be a nonblank string`);
616
+ throw new Error(
617
+ `playbooks.${id}.command must be a canonical trimmed nonblank string`,
618
+ );
253
619
  }
254
620
  if (block.command === RESERVED_CAPTAIN_PLAYBOOK_ID) {
255
621
  throw new Error(
256
622
  `playbooks.${id}.command collides with the reserved internal Captain command`,
257
623
  );
258
624
  }
259
- const playersMap = requireObject(block.players, `playbooks.${id}.players`);
260
- const roles = Object.keys(playersMap);
261
- if (roles.length === 0) {
262
- throw new Error(`playbooks.${id} resolves no visible local role`);
263
- }
625
+ const rolesMap = requireObject(block.roles, `playbooks.${id}.roles`);
626
+ const roles = Object.keys(rolesMap);
264
627
  if (roles.some((role) => role.trim().length === 0)) {
265
- throw new Error(`playbooks.${id}.players keys must be nonblank role ids`);
628
+ throw new Error(`playbooks.${id}.roles keys must be nonblank role ids`);
266
629
  }
267
630
  if (roles.includes(RESERVED_CAPTAIN_ROLE_ID)) {
268
631
  throw new Error(
269
- `playbooks.${id}.players.${RESERVED_CAPTAIN_ROLE_ID} binds local ` +
632
+ `playbooks.${id}.roles.${RESERVED_CAPTAIN_ROLE_ID} binds local ` +
270
633
  `role "${RESERVED_CAPTAIN_ROLE_ID}", which is reserved for the ` +
271
634
  'tmux-play Captain',
272
635
  );
273
636
  }
274
- const normalizedPlayers = [];
275
- const generated = [];
276
- const playerIdEntries = [];
637
+ const bindings = Object.create(null);
277
638
  for (const role of roles) {
278
- const agent = resolveAgent(
279
- playersMap[role],
280
- `playbooks.${id}.players.${role}`,
281
- ['id'],
639
+ assertRoleId(role, `playbooks.${id}.roles.${role}`);
640
+ const binding = resolveRoleBinding(
641
+ rolesMap[role],
642
+ `playbooks.${id}.roles.${role}`,
282
643
  );
283
- if (
284
- typeof agent.adapter !== 'string' ||
285
- agent.adapter.trim().length === 0
286
- ) {
644
+ const agent = configuredAgents.get(binding.playerId);
645
+ if (agent === undefined) {
287
646
  throw new Error(
288
- `playbooks.${id}.players.${role} must resolve an adapter`,
647
+ `playbooks.${id}.roles.${role} names unknown player ` +
648
+ JSON.stringify(binding.playerId),
289
649
  );
290
650
  }
291
- const hostId = `${id}-${role}`;
292
- if (seenHostIds.has(hostId)) {
293
- throw new Error(`generated host player id "${hostId}" is not unique`);
651
+ bindings[role] = binding;
652
+ if (binding.model !== undefined || binding.effort !== undefined) {
653
+ let checkId;
654
+ do {
655
+ checkId = `binding-check-${tuningCheckIndex}`;
656
+ tuningCheckIndex += 1;
657
+ } while (configuredAgents.has(checkId));
658
+ tuningChecks.push({
659
+ id: checkId,
660
+ agent: applyTuningOverrides(agent, binding),
661
+ });
294
662
  }
295
- seenHostIds.add(hostId);
296
- normalizedPlayers.push({
297
- id: hostId,
298
- playbookId: id,
299
- roleId: role,
300
- agent,
301
- });
302
- playerIdEntries.push([role, hostId]);
303
- generated.push(hostId);
304
663
  }
305
664
  const optionSlice = Object.fromEntries(
306
665
  Object.entries(block).filter(
@@ -314,9 +673,7 @@ export async function normalizeLaunchPlan(
314
673
  configuredFrom,
315
674
  commandOverride: block.command,
316
675
  roles,
317
- normalizedPlayers,
318
- generated,
319
- playerIds: Object.fromEntries(playerIdEntries),
676
+ bindings,
320
677
  optionSlice,
321
678
  });
322
679
  }
@@ -325,22 +682,40 @@ export async function normalizeLaunchPlan(
325
682
  // detached provisional projection before any registry preparation/import,
326
683
  // then feed its agent and presentation fields back into the authoritative
327
684
  // plan. The interactive child will only revalidate these same values.
328
- const firstVisible = configuredPlaybooks[0].generated;
685
+ const firstRoleful = configuredPlaybooks.find(
686
+ (configured) => configured.roles.length > 0,
687
+ );
688
+ const provisionalVisible =
689
+ firstRoleful === undefined
690
+ ? []
691
+ : distinct(
692
+ firstRoleful.roles.map(
693
+ (role) => firstRoleful.bindings[role].playerId,
694
+ ),
695
+ );
696
+ // An all-roleless catalog still validates every authored player through
697
+ // cligent, but the validation-only projection must respect tmux-play's
698
+ // invariant that a nonempty roster has at least one visible pane.
699
+ const validationVisible =
700
+ provisionalVisible.length === 0
701
+ ? allPlayerIds.slice(0, 1)
702
+ : provisionalVisible;
329
703
  const provisional = {
330
704
  captain: {
331
705
  ...captain,
332
706
  from: PLAYBOOK_CAPTAIN_MODULE,
333
707
  options: {},
334
708
  },
335
- players: configuredPlaybooks.flatMap((configured) =>
336
- configured.normalizedPlayers.map(({ id, agent }) => ({
709
+ players: [
710
+ ...allPlayerIds.map((id) => ({
337
711
  id,
338
- ...agent,
712
+ ...configuredAgents.get(id),
339
713
  })),
340
- ),
714
+ ...tuningChecks.map(({ id, agent }) => ({ id, ...agent })),
715
+ ],
341
716
  layout: {
342
717
  ...(isObject(top.layout) ? top.layout : {}),
343
- initialVisible: firstVisible,
718
+ initialVisible: validationVisible,
344
719
  },
345
720
  ...(top.notifications === undefined
346
721
  ? {}
@@ -348,17 +723,30 @@ export async function normalizeLaunchPlan(
348
723
  ...(top.theme === undefined ? {} : { theme: top.theme }),
349
724
  };
350
725
  const normalizedHost = await normalizeHostConfig(provisional);
726
+ // The authoritative all-roleless projection is Boss-only. Normalize that
727
+ // exact empty host shape separately so derived presentation aliases (most
728
+ // notably active `columnWeights`) match zero visible player panes rather
729
+ // than the temporary validation pane above.
730
+ const normalizedPresentationHost =
731
+ provisionalVisible.length === 0 && allPlayerIds.length > 0
732
+ ? await normalizeHostConfig({
733
+ ...provisional,
734
+ players: [],
735
+ layout: { ...provisional.layout, initialVisible: [] },
736
+ })
737
+ : normalizedHost;
351
738
  const { from: _captainFrom, options: _captainOptions, ...normalizedCaptain } =
352
739
  normalizedHost.captain;
353
- captain = normalizedCaptain;
740
+ captain = sessionAgentFromHostAgent(normalizedCaptain, 'captain');
354
741
  const hostAgents = new Map(
355
742
  normalizedHost.players.map(({ id, ...agent }) => [id, agent]),
356
743
  );
357
- for (const configured of configuredPlaybooks) {
358
- configured.normalizedPlayers = configured.normalizedPlayers.map(
359
- (player) => ({ ...player, agent: hostAgents.get(player.id) }),
360
- );
361
- }
744
+ const normalizedPlayerAgents = new Map(
745
+ allPlayerIds.map((id) => [
746
+ id,
747
+ sessionAgentFromHostAgent(hostAgents.get(id), `players.${id}`),
748
+ ]),
749
+ );
362
750
 
363
751
  // Preparation is a transaction-like pre-import phase across the complete
364
752
  // configured catalog. A provisioning failure therefore cannot leave some
@@ -390,11 +778,14 @@ export async function normalizeLaunchPlan(
390
778
  );
391
779
  }
392
780
  }
781
+ preparedFrom = canonicalizePreparedRegistrySpecifier(
782
+ preparedFrom,
783
+ `playbooks.${id}.from`,
784
+ );
393
785
  preparedPlaybooks.push({ ...configured, preparedFrom });
394
786
  }
395
787
 
396
788
  const catalogEntries = [];
397
- const players = [];
398
789
  const seenCommands = new Map();
399
790
  const seenIds = new Set();
400
791
 
@@ -404,8 +795,7 @@ export async function normalizeLaunchPlan(
404
795
  preparedFrom,
405
796
  commandOverride,
406
797
  roles,
407
- normalizedPlayers,
408
- playerIds,
798
+ bindings,
409
799
  optionSlice,
410
800
  } of preparedPlaybooks) {
411
801
  let mod;
@@ -417,9 +807,11 @@ export async function normalizeLaunchPlan(
417
807
  );
418
808
  }
419
809
  const entry = mod?.default;
420
- if (!isValidRegistryEntry(entry)) {
810
+ const registryProblem = invalidRegistryEntryReason(entry);
811
+ if (registryProblem !== undefined) {
421
812
  throw new Error(
422
- `playbooks.${id}.from "${from}" exposes no valid registry entry`,
813
+ `playbooks.${id}.from "${from}" exposes no valid registry entry: ` +
814
+ registryProblem,
423
815
  );
424
816
  }
425
817
  if (entry.id !== id) {
@@ -443,21 +835,29 @@ export async function normalizeLaunchPlan(
443
835
  }
444
836
  seenCommands.set(command, id);
445
837
 
446
- if (entry.requiredRoleIds.includes(RESERVED_CAPTAIN_ROLE_ID)) {
447
- throw new Error(
448
- `playbooks.${id} requires local role "${RESERVED_CAPTAIN_ROLE_ID}", ` +
449
- 'which is reserved for the tmux-play Captain',
450
- );
451
- }
452
- for (const required of entry.requiredRoleIds) {
453
- if (!roles.includes(required)) {
454
- throw new Error(
455
- `playbooks.${id} required role "${required}" has no players entry`,
456
- );
457
- }
458
- }
838
+ assertExactRoleBindings(id, roles, entry.requiredRoleIds);
839
+ const resolvedRoles = Object.fromEntries(
840
+ entry.requiredRoleIds.map((role) => {
841
+ const binding = bindings[role];
842
+ const agent = normalizedPlayerAgents.get(binding.playerId);
843
+ return [
844
+ role,
845
+ {
846
+ playerId: binding.playerId,
847
+ model:
848
+ binding.model === undefined
849
+ ? agent.model
850
+ : overrideTuningSelection(binding.model),
851
+ effort:
852
+ binding.effort === undefined
853
+ ? agent.effort
854
+ : overrideTuningSelection(binding.effort),
855
+ },
856
+ ];
857
+ }),
858
+ );
859
+ assertConcurrentPlayers(id, entry.concurrentRoleSets, resolvedRoles);
459
860
 
460
- players.push(...normalizedPlayers);
461
861
  catalogEntries.push([
462
862
  id,
463
863
  {
@@ -470,19 +870,43 @@ export async function normalizeLaunchPlan(
470
870
  command,
471
871
  ...(commandOverride === undefined ? {} : { commandOverride }),
472
872
  intent: entry.intent,
873
+ artifactSchema: entry.artifactSchema,
473
874
  requiredRoleIds: [...entry.requiredRoleIds],
474
- playerIds,
875
+ concurrentRoleSets: entry.concurrentRoleSets.map((set) => [...set]),
876
+ roles: resolvedRoles,
475
877
  options: optionSlice,
476
878
  },
477
879
  ]);
478
880
  }
479
881
 
882
+ // Registry role order is the canonical role order. Derive the one roster
883
+ // union only after every exact role map is closed against its manifest.
884
+ const referencedPlayerIds = distinct(
885
+ catalogEntries.flatMap(([, item]) =>
886
+ Object.values(item.roles).map((binding) => binding.playerId),
887
+ ),
888
+ );
889
+
890
+ const firstVisibleCatalog = catalogEntries.find(
891
+ ([, item]) => Object.keys(item.roles).length > 0,
892
+ );
893
+ const initialVisible =
894
+ firstVisibleCatalog === undefined
895
+ ? []
896
+ : distinct(
897
+ Object.values(firstVisibleCatalog[1].roles).map(
898
+ (binding) => binding.playerId,
899
+ ),
900
+ );
480
901
  const presentation = {
481
- layout: normalizedHost.layout,
482
- notifications: normalizedHost.notifications,
483
- ...(normalizedHost.theme === undefined
902
+ layout: {
903
+ ...normalizedPresentationHost.layout,
904
+ initialVisible,
905
+ },
906
+ notifications: normalizedPresentationHost.notifications,
907
+ ...(normalizedPresentationHost.theme === undefined
484
908
  ? {}
485
- : { theme: normalizedHost.theme }),
909
+ : { theme: normalizedPresentationHost.theme }),
486
910
  };
487
911
 
488
912
  return deepFreeze(
@@ -490,7 +914,10 @@ export async function normalizeLaunchPlan(
490
914
  {
491
915
  schemaVersion: 1,
492
916
  captain,
493
- players,
917
+ players: referencedPlayerIds.map((id) => ({
918
+ id,
919
+ agent: normalizedPlayerAgents.get(id),
920
+ })),
494
921
  catalog: Object.fromEntries(catalogEntries),
495
922
  presentation,
496
923
  },
@@ -522,16 +949,26 @@ export function projectTmuxConfig(plan) {
522
949
  {
523
950
  from: item.from,
524
951
  command: item.command,
952
+ roles: cloneJson(item.roles, `catalog.${id}.roles`),
525
953
  options: cloneJson(item.options, `catalog.${id}.options`),
526
954
  },
527
955
  ]),
528
956
  );
529
957
  const captain = {
530
- ...cloneJson(plan.captain, 'captain'),
958
+ ...projectHostAgent(plan.captain, 'captain'),
531
959
  from: PLAYBOOK_CAPTAIN_MODULE,
532
960
  };
533
961
  captain.options = {
534
962
  playbooks,
963
+ sessionAgents: {
964
+ captain: cloneJson(plan.captain, 'captain'),
965
+ players: Object.fromEntries(
966
+ plan.players.map(({ id, agent }) => [
967
+ id,
968
+ cloneJson(agent, `players.${id}.agent`),
969
+ ]),
970
+ ),
971
+ },
535
972
  ...(typeof captain.adapter === 'string' && captain.adapter.length > 0
536
973
  ? { captainAdapter: captain.adapter }
537
974
  : {}),
@@ -539,7 +976,7 @@ export function projectTmuxConfig(plan) {
539
976
  const config = {
540
977
  captain,
541
978
  players: plan.players.map(({ id, agent }) => ({
542
- ...cloneJson(agent, `players.${id}.agent`),
979
+ ...projectHostAgent(agent, `players.${id}.agent`),
543
980
  id,
544
981
  })),
545
982
  layout: projectHostLayout(plan.presentation.layout),
@@ -653,6 +1090,9 @@ function migrateUserConfigIfRetired(userConfigPath, onNotice) {
653
1090
  try {
654
1091
  migrated = migrateRetiredProfiles(text);
655
1092
  } catch (error) {
1093
+ if (error?.code === 'PLAYBOOK_LEGACY_PLAYERS') {
1094
+ throw legacyPlayersError(error.legacyPath, userConfigPath);
1095
+ }
656
1096
  throw new Error(
657
1097
  `cannot migrate the retired profiles config at ${userConfigPath}: ` +
658
1098
  `${errorMessage(error)} — edit it by hand: each agent takes its own ` +
@@ -684,19 +1124,23 @@ export function migrateRetiredProfiles(text) {
684
1124
  const doc = parseYamlDocument(text);
685
1125
  const contents = doc.contents;
686
1126
  if (!contents || !Array.isArray(contents.items)) return undefined;
687
- const profiles = doc.get('profiles');
688
- const agentPaths = [['captain']];
689
1127
  const playbooks = doc.get('playbooks');
690
1128
  if (playbooks && Array.isArray(playbooks.items)) {
691
1129
  for (const entry of playbooks.items) {
692
1130
  const id = String(entry.key);
693
- const players = doc.getIn(['playbooks', id, 'players']);
694
- if (!players || !Array.isArray(players.items)) continue;
695
- for (const player of players.items) {
696
- agentPaths.push(['playbooks', id, 'players', String(player.key)]);
1131
+ if (doc.getIn(['playbooks', id, 'players']) !== undefined) {
1132
+ throw legacyPlayersError(`playbooks.${id}.players`);
697
1133
  }
698
1134
  }
699
1135
  }
1136
+ const profiles = doc.get('profiles');
1137
+ const agentPaths = [['captain']];
1138
+ const players = doc.get('players');
1139
+ if (players && Array.isArray(players.items)) {
1140
+ for (const player of players.items) {
1141
+ agentPaths.push(['players', String(player.key)]);
1142
+ }
1143
+ }
700
1144
 
701
1145
  const profileSettings = (name) =>
702
1146
  profiles && typeof profiles.get === 'function'
@@ -787,18 +1231,16 @@ function assertNoRetiredProfiles(top, configPath) {
787
1231
  if (top.profiles !== undefined) {
788
1232
  throw new Error(
789
1233
  `top-level "profiles" was removed${where}: write each agent's settings ` +
790
- 'inline under captain and each playbooks.<id>.players.<role> ' +
1234
+ 'inline under captain and each top-level players.<player-id> ' +
791
1235
  '(adapter, model, effort, permissions)',
792
1236
  );
793
1237
  }
1238
+ const legacyPath = findLegacyPlayersPath(top);
1239
+ if (legacyPath !== undefined) throw legacyPlayersError(legacyPath, configPath);
794
1240
  const blocks = [['captain', top.captain]];
795
- const playbooksCfg = isObject(top.playbooks) ? top.playbooks : {};
796
- for (const [id, block] of Object.entries(playbooksCfg)) {
797
- const playersMap =
798
- isObject(block) && isObject(block.players) ? block.players : {};
799
- for (const [role, agent] of Object.entries(playersMap)) {
800
- blocks.push([`playbooks.${id}.players.${role}`, agent]);
801
- }
1241
+ const playersCfg = isObject(top.players) ? top.players : {};
1242
+ for (const [playerId, agent] of Object.entries(playersCfg)) {
1243
+ blocks.push([`players.${playerId}`, agent]);
802
1244
  }
803
1245
  for (const [path, block] of blocks) {
804
1246
  if (isObject(block) && block.profile !== undefined) {
@@ -810,22 +1252,536 @@ function assertNoRetiredProfiles(top, configPath) {
810
1252
  }
811
1253
  }
812
1254
 
813
- function isValidRegistryEntry(value) {
814
- if (!isObject(value)) return false;
815
- return (
816
- typeof value.id === 'string' &&
817
- value.id.trim().length > 0 &&
818
- typeof value.command === 'string' &&
819
- value.command.trim().length > 0 &&
820
- typeof value.intent === 'string' &&
821
- Array.isArray(value.requiredRoleIds) &&
822
- value.requiredRoleIds.every(
823
- (role) => typeof role === 'string' && role.trim().length > 0,
824
- ) &&
825
- new Set(value.requiredRoleIds).size === value.requiredRoleIds.length &&
826
- typeof value.validateOptions === 'function' &&
827
- typeof value.createRuntime === 'function'
1255
+ function invalidRegistryEntryReason(value) {
1256
+ if (!isObject(value)) return 'the default export must be an object';
1257
+ if (
1258
+ typeof value.id !== 'string' ||
1259
+ value.id.trim().length === 0 ||
1260
+ value.id !== value.id.trim()
1261
+ ) {
1262
+ return 'id must be a canonical trimmed nonblank string';
1263
+ }
1264
+ if (
1265
+ typeof value.command !== 'string' ||
1266
+ value.command.trim().length === 0 ||
1267
+ value.command !== value.command.trim()
1268
+ ) {
1269
+ return 'command must be a canonical trimmed nonblank string';
1270
+ }
1271
+ if (typeof value.intent !== 'string') return 'intent must be a string';
1272
+ if (value.artifactSchema !== 2) {
1273
+ return 'artifactSchema must be exactly 2';
1274
+ }
1275
+ const roleProblem = invalidManifestRoles(value.requiredRoleIds);
1276
+ if (roleProblem !== undefined) return `requiredRoleIds ${roleProblem}`;
1277
+ const concurrentProblem = invalidConcurrentRoleSets(
1278
+ value.concurrentRoleSets,
1279
+ value.requiredRoleIds,
1280
+ );
1281
+ if (concurrentProblem !== undefined) {
1282
+ return `concurrentRoleSets ${concurrentProblem}`;
1283
+ }
1284
+ if (typeof value.validateOptions !== 'function') {
1285
+ return 'validateOptions must be a function';
1286
+ }
1287
+ if (typeof value.createRuntime !== 'function') {
1288
+ return 'createRuntime must be a function';
1289
+ }
1290
+ return undefined;
1291
+ }
1292
+
1293
+ function validateStoredStructuralProjection(value) {
1294
+ const stored = cloneJson(value, 'stored structural projection');
1295
+ if (!isPlainObject(stored) || stored.schemaVersion !== 1) {
1296
+ throw new Error('stored structural projection schema 1 is required');
1297
+ }
1298
+ const captain = requireObject(stored.captain, 'stored structural captain');
1299
+ if (typeof captain.adapter !== 'string' || captain.adapter.length === 0) {
1300
+ throw new Error('stored structural captain must name an adapter');
1301
+ }
1302
+ if (!Array.isArray(stored.players) || !isPlainObject(stored.catalog)) {
1303
+ throw new Error(
1304
+ 'stored structural projection must contain players and catalog',
1305
+ );
1306
+ }
1307
+ const playerIds = stored.players.map((player, index) => {
1308
+ const record = requireObject(player, `stored structural players.${index}`);
1309
+ assertPlayerId(record.id, `stored structural players.${index}.id`);
1310
+ if (typeof record.adapter !== 'string' || record.adapter.length === 0) {
1311
+ throw new Error(
1312
+ `stored structural players.${index} must name an adapter`,
1313
+ );
1314
+ }
1315
+ return record.id;
1316
+ });
1317
+ if (new Set(playerIds).size !== playerIds.length) {
1318
+ throw new Error('stored structural player ids must be unique');
1319
+ }
1320
+ for (const [id, itemValue] of Object.entries(stored.catalog)) {
1321
+ const item = requireObject(itemValue, `stored structural catalog.${id}`);
1322
+ if (item.id !== id || id === RESERVED_CAPTAIN_PLAYBOOK_ID) {
1323
+ throw new Error(`stored structural catalog.${id}.id is invalid`);
1324
+ }
1325
+ for (const field of [
1326
+ 'from',
1327
+ 'manifestCommand',
1328
+ 'command',
1329
+ 'intent',
1330
+ ]) {
1331
+ if (typeof item[field] !== 'string') {
1332
+ throw new Error(`stored structural catalog.${id}.${field} is invalid`);
1333
+ }
1334
+ }
1335
+ if (
1336
+ !Array.isArray(item.requiredRoleIds) ||
1337
+ !Array.isArray(item.concurrentRoleSets) ||
1338
+ !isPlainObject(item.roles) ||
1339
+ !isPlainObject(item.options)
1340
+ ) {
1341
+ throw new Error(`stored structural catalog.${id} is malformed`);
1342
+ }
1343
+ if (
1344
+ JSON.stringify(Object.keys(item.roles)) !==
1345
+ JSON.stringify(item.requiredRoleIds)
1346
+ ) {
1347
+ throw new Error(
1348
+ `stored structural catalog.${id}.roles must follow requiredRoleIds`,
1349
+ );
1350
+ }
1351
+ for (const role of item.requiredRoleIds) {
1352
+ assertRoleId(role, `stored structural catalog.${id}.roles.${role}`);
1353
+ const binding = requireObject(
1354
+ item.roles[role],
1355
+ `stored structural catalog.${id}.roles.${role}`,
1356
+ );
1357
+ if (!playerIds.includes(binding.playerId)) {
1358
+ throw new Error(
1359
+ `stored structural catalog.${id}.roles.${role} names an absent player`,
1360
+ );
1361
+ }
1362
+ }
1363
+ }
1364
+ return stored;
1365
+ }
1366
+
1367
+ function selectedMembersFromStoredStructure(stored) {
1368
+ return {
1369
+ playbookIds: Object.keys(stored.catalog),
1370
+ playerIds: stored.players.map((player) => player.id),
1371
+ };
1372
+ }
1373
+
1374
+ function fixedAgentProjection(agent) {
1375
+ return {
1376
+ adapter: agent.adapter,
1377
+ ...(agent.instruction === undefined
1378
+ ? {}
1379
+ : { instruction: agent.instruction }),
1380
+ ...(agent.permissions === undefined
1381
+ ? {}
1382
+ : { permissions: agent.permissions }),
1383
+ };
1384
+ }
1385
+
1386
+ function projectSelectedMembers(top, selectedMembers) {
1387
+ if (selectedMembers === undefined) return top;
1388
+ const { playbookIds, playerIds } = validateSelectedMembers(selectedMembers);
1389
+ if (!isPlainObject(top)) {
1390
+ throw new Error('config must contain only plain JSON objects');
1391
+ }
1392
+
1393
+ const descriptors = Object.getOwnPropertyDescriptors(top);
1394
+ const keys = Reflect.ownKeys(top);
1395
+ for (const key of keys) {
1396
+ const descriptor = descriptors[key];
1397
+ if (
1398
+ typeof key === 'symbol' ||
1399
+ descriptor?.get !== undefined ||
1400
+ descriptor?.set !== undefined ||
1401
+ descriptor?.enumerable !== true
1402
+ ) {
1403
+ throw new Error(
1404
+ `config.${String(key)} must be an enumerable data property`,
1405
+ );
1406
+ }
1407
+ }
1408
+ const projected = Object.fromEntries(
1409
+ keys.map((key) => [key, descriptors[key].value]),
1410
+ );
1411
+ projected.playbooks = projectSelectedMap(
1412
+ projected.playbooks,
1413
+ playbookIds,
1414
+ 'playbooks',
828
1415
  );
1416
+ projected.players = projectSelectedMap(
1417
+ projected.players,
1418
+ playerIds,
1419
+ 'players',
1420
+ );
1421
+ return projected;
1422
+ }
1423
+
1424
+ function validateSelectedMembers(selectedMembers) {
1425
+ const selected = cloneJson(selectedMembers, 'selectedMembers');
1426
+ const unknownSelectionKeys = Object.keys(selected).filter(
1427
+ (key) => !['playbookIds', 'playerIds'].includes(key),
1428
+ );
1429
+ if (unknownSelectionKeys.length > 0) {
1430
+ throw new Error(
1431
+ `selectedMembers has unknown ${formatKeyList(unknownSelectionKeys)}`,
1432
+ );
1433
+ }
1434
+ return {
1435
+ playbookIds: selectedIdList(
1436
+ selected.playbookIds,
1437
+ 'selectedMembers.playbookIds',
1438
+ ),
1439
+ playerIds: selectedIdList(
1440
+ selected.playerIds,
1441
+ 'selectedMembers.playerIds',
1442
+ ),
1443
+ };
1444
+ }
1445
+
1446
+ function projectSelectedLayer(value, selected, path) {
1447
+ if (!isPlainObject(value)) {
1448
+ throw new Error(`${path} must contain only plain JSON objects`);
1449
+ }
1450
+ const descriptors = Object.getOwnPropertyDescriptors(value);
1451
+ const entries = [];
1452
+ for (const key of Reflect.ownKeys(value)) {
1453
+ const descriptor = descriptors[key];
1454
+ if (
1455
+ typeof key === 'symbol' ||
1456
+ descriptor?.get !== undefined ||
1457
+ descriptor?.set !== undefined ||
1458
+ descriptor?.enumerable !== true
1459
+ ) {
1460
+ throw new Error(
1461
+ `${path}.${String(key)} must be an enumerable data property`,
1462
+ );
1463
+ }
1464
+ if (key === 'playbooks' || key === 'players') {
1465
+ entries.push([
1466
+ key,
1467
+ projectOptionalSelectedMap(
1468
+ descriptor.value,
1469
+ key === 'playbooks' ? selected.playbookIds : selected.playerIds,
1470
+ `${path}.${key}`,
1471
+ ),
1472
+ ]);
1473
+ } else {
1474
+ entries.push([key, descriptor.value]);
1475
+ }
1476
+ }
1477
+ return Object.fromEntries(entries);
1478
+ }
1479
+
1480
+ function projectOptionalSelectedMap(value, ids, path) {
1481
+ if (!isPlainObject(value)) {
1482
+ throw new Error(`${path} must contain only plain JSON objects`);
1483
+ }
1484
+ const entries = [];
1485
+ for (const id of ids) {
1486
+ const descriptor = Object.getOwnPropertyDescriptor(value, id);
1487
+ if (descriptor === undefined) continue;
1488
+ if (
1489
+ descriptor.get !== undefined ||
1490
+ descriptor.set !== undefined ||
1491
+ descriptor.enumerable !== true
1492
+ ) {
1493
+ throw new Error(`${path}.${id} must be an enumerable data property`);
1494
+ }
1495
+ entries.push([id, descriptor.value]);
1496
+ }
1497
+ return Object.fromEntries(entries);
1498
+ }
1499
+
1500
+ function projectSelectedMap(value, ids, path) {
1501
+ if (!isPlainObject(value)) {
1502
+ throw new Error(`${path} must contain only plain JSON objects`);
1503
+ }
1504
+ return Object.fromEntries(
1505
+ ids.map((id) => {
1506
+ const descriptor = Object.getOwnPropertyDescriptor(value, id);
1507
+ if (descriptor === undefined) {
1508
+ throw new Error(
1509
+ `selected ${path} member ${JSON.stringify(id)} is missing`,
1510
+ );
1511
+ }
1512
+ if (
1513
+ descriptor.get !== undefined ||
1514
+ descriptor.set !== undefined ||
1515
+ descriptor.enumerable !== true
1516
+ ) {
1517
+ throw new Error(`${path}.${id} must be an enumerable data property`);
1518
+ }
1519
+ return [id, descriptor.value];
1520
+ }),
1521
+ );
1522
+ }
1523
+
1524
+ function selectedIdList(value, path) {
1525
+ if (
1526
+ !Array.isArray(value) ||
1527
+ value.some((id) => typeof id !== 'string' || id.trim().length === 0) ||
1528
+ new Set(value).size !== value.length
1529
+ ) {
1530
+ throw new Error(`${path} must be a duplicate-free array of nonblank ids`);
1531
+ }
1532
+ return value;
1533
+ }
1534
+
1535
+ function resolveRoleBinding(value, path) {
1536
+ if (typeof value === 'string') {
1537
+ assertPlayerId(value, path);
1538
+ return { playerId: value };
1539
+ }
1540
+ const block = requireObject(value, path);
1541
+ const unknown = Object.keys(block).filter(
1542
+ (key) => !['player', 'model', 'effort'].includes(key),
1543
+ );
1544
+ if (unknown.length > 0) {
1545
+ throw new Error(`${path} has unknown ${formatKeyList(unknown)}`);
1546
+ }
1547
+ assertPlayerId(block.player, `${path}.player`);
1548
+ for (const field of ['model', 'effort']) {
1549
+ if (
1550
+ block[field] !== undefined &&
1551
+ block[field] !== false &&
1552
+ (typeof block[field] !== 'string' || block[field].trim().length === 0)
1553
+ ) {
1554
+ throw new Error(
1555
+ `${path}.${field} must be a nonblank string or false for provider-default`,
1556
+ );
1557
+ }
1558
+ }
1559
+ return {
1560
+ playerId: block.player,
1561
+ ...(block.model === undefined ? {} : { model: block.model }),
1562
+ ...(block.effort === undefined ? {} : { effort: block.effort }),
1563
+ };
1564
+ }
1565
+
1566
+ function applyTuningOverrides(agent, binding) {
1567
+ const effective = { ...agent };
1568
+ if (binding.model === false) delete effective.model;
1569
+ else if (binding.model !== undefined) effective.model = binding.model;
1570
+ if (binding.effort !== undefined) {
1571
+ delete effective.reasoningEffort;
1572
+ if (binding.effort === false) delete effective.effort;
1573
+ else effective.effort = binding.effort;
1574
+ }
1575
+ return effective;
1576
+ }
1577
+
1578
+ function sessionAgentFromHostAgent(agent, path) {
1579
+ if (!isObject(agent)) {
1580
+ throw new Error('installed cligent omitted a retained agent');
1581
+ }
1582
+ return {
1583
+ adapter: agent.adapter,
1584
+ model: tuningSelection(agent.model, `${path}.model`),
1585
+ effort: tuningSelection(agent.effort, `${path}.effort`),
1586
+ ...(agent.instruction === undefined
1587
+ ? {}
1588
+ : { instruction: agent.instruction }),
1589
+ ...(agent.permissions === undefined
1590
+ ? {}
1591
+ : { permissions: agent.permissions }),
1592
+ };
1593
+ }
1594
+
1595
+ // Shared by the interactive and headless host projections. A tagged
1596
+ // provider-default is represented to cligent by omitting that configured
1597
+ // default; the complete tagged selection remains in sessionAgents.
1598
+ export function projectHostAgent(agent, path = 'agent') {
1599
+ const normalized = cloneJson(agent, path);
1600
+ return {
1601
+ adapter: normalized.adapter,
1602
+ ...(normalized.model?.kind === 'value'
1603
+ ? { model: normalized.model.value }
1604
+ : {}),
1605
+ ...(normalized.effort?.kind === 'value'
1606
+ ? { effort: normalized.effort.value }
1607
+ : {}),
1608
+ ...(normalized.instruction === undefined
1609
+ ? {}
1610
+ : { instruction: normalized.instruction }),
1611
+ ...(normalized.permissions === undefined
1612
+ ? {}
1613
+ : { permissions: normalized.permissions }),
1614
+ };
1615
+ }
1616
+
1617
+ function tuningSelection(value, path) {
1618
+ if (
1619
+ value !== undefined &&
1620
+ (typeof value !== 'string' || value.trim().length === 0)
1621
+ ) {
1622
+ throw new Error(`${path} must be a nonblank string`);
1623
+ }
1624
+ return value === undefined
1625
+ ? { kind: 'provider-default' }
1626
+ : { kind: 'value', value };
1627
+ }
1628
+
1629
+ function overrideTuningSelection(value) {
1630
+ return value === false
1631
+ ? { kind: 'provider-default' }
1632
+ : tuningSelection(value, 'role tuning override');
1633
+ }
1634
+
1635
+ function canonicalizePreparedRegistrySpecifier(value, path) {
1636
+ if (value !== value.trim()) {
1637
+ throw new Error(
1638
+ `${path} preparation must return a canonical trimmed module specifier`,
1639
+ );
1640
+ }
1641
+ if (
1642
+ isAbsolute(value) ||
1643
+ /^(?:\.{1,2}(?:[\\/]|$)|[\\/]|[A-Za-z]:[\\/])/.test(value)
1644
+ ) {
1645
+ throw new Error(
1646
+ `${path} preparation must return a canonical module specifier`,
1647
+ );
1648
+ }
1649
+ if (value.startsWith('file:')) {
1650
+ let canonical;
1651
+ try {
1652
+ canonical = pathToFileURL(fileURLToPath(value)).href;
1653
+ } catch {
1654
+ throw new Error(`${path} preparation must return a canonical file URL`);
1655
+ }
1656
+ if (canonical !== value) {
1657
+ throw new Error(`${path} preparation must return a canonical file URL`);
1658
+ }
1659
+ }
1660
+ return value;
1661
+ }
1662
+
1663
+ function assertPlayerId(value, path) {
1664
+ if (typeof value !== 'string' || !PLAYER_ID_PATTERN.test(value)) {
1665
+ throw new Error(
1666
+ `${path} must name a player matching ${PLAYER_ID_PATTERN.source}`,
1667
+ );
1668
+ }
1669
+ if (value === RESERVED_CAPTAIN_ROLE_ID) {
1670
+ throw new Error(`${path} uses reserved player id "captain"`);
1671
+ }
1672
+ }
1673
+
1674
+ function assertRoleId(value, path) {
1675
+ if (typeof value !== 'string' || !ROLE_ID_PATTERN.test(value)) {
1676
+ throw new Error(`${path} must use a canonical lowercase local role id`);
1677
+ }
1678
+ if (value === RESERVED_CAPTAIN_ROLE_ID) {
1679
+ throw new Error(`${path} uses reserved local role id "captain"`);
1680
+ }
1681
+ }
1682
+
1683
+ function invalidManifestRoles(value) {
1684
+ if (!Array.isArray(value)) return 'must be an array';
1685
+ if (value.some((role) => typeof role !== 'string')) {
1686
+ return 'must contain only strings';
1687
+ }
1688
+ const canonical = value.map((role) => role.toLowerCase());
1689
+ if (new Set(canonical).size !== canonical.length) {
1690
+ return 'contains roles that collide after canonical lowercase derivation';
1691
+ }
1692
+ const invalid = value.find((role) => !ROLE_ID_PATTERN.test(role));
1693
+ if (invalid !== undefined) {
1694
+ return `contains noncanonical role ${JSON.stringify(invalid)}`;
1695
+ }
1696
+ if (value.includes(RESERVED_CAPTAIN_ROLE_ID)) {
1697
+ return 'contains reserved local role "captain"';
1698
+ }
1699
+ return undefined;
1700
+ }
1701
+
1702
+ function invalidConcurrentRoleSets(value, requiredRoleIds) {
1703
+ if (!Array.isArray(value)) return 'must be an array';
1704
+ const required = new Set(requiredRoleIds);
1705
+ const seen = new Set();
1706
+ for (let index = 0; index < value.length; index += 1) {
1707
+ const set = value[index];
1708
+ if (!Array.isArray(set) || set.length < 2) {
1709
+ return `[${index}] must contain at least two roles`;
1710
+ }
1711
+ if (
1712
+ set.some(
1713
+ (role) =>
1714
+ typeof role !== 'string' ||
1715
+ !ROLE_ID_PATTERN.test(role) ||
1716
+ role === RESERVED_CAPTAIN_ROLE_ID ||
1717
+ !required.has(role),
1718
+ )
1719
+ ) {
1720
+ return `[${index}] must contain only required canonical local roles`;
1721
+ }
1722
+ if (new Set(set).size !== set.length) {
1723
+ return `[${index}] must contain pairwise-distinct roles`;
1724
+ }
1725
+ const signature = JSON.stringify(set);
1726
+ if (seen.has(signature)) return `contains duplicate set ${signature}`;
1727
+ seen.add(signature);
1728
+ }
1729
+ return undefined;
1730
+ }
1731
+
1732
+ function assertExactRoleBindings(id, configured, required) {
1733
+ const missing = required.filter((role) => !configured.includes(role));
1734
+ const extra = configured.filter((role) => !required.includes(role));
1735
+ if (missing.length > 0 || extra.length > 0) {
1736
+ throw new Error(
1737
+ `playbooks.${id}.roles must exactly cover requiredRoleIds` +
1738
+ `${missing.length === 0 ? '' : `; missing ${missing.map(JSON.stringify).join(', ')}`}` +
1739
+ `${
1740
+ extra.length === 0
1741
+ ? ''
1742
+ : `; extra ${extra.map(JSON.stringify).join(', ')}`
1743
+ }`,
1744
+ );
1745
+ }
1746
+ }
1747
+
1748
+ function assertConcurrentPlayers(id, sets, roles) {
1749
+ for (const set of sets) {
1750
+ const playerIds = set.map((role) => roles[role].playerId);
1751
+ if (new Set(playerIds).size !== playerIds.length) {
1752
+ throw new Error(
1753
+ `playbooks.${id}.concurrentRoleSets ${JSON.stringify(set)} must bind ` +
1754
+ 'to pairwise-distinct player ids',
1755
+ );
1756
+ }
1757
+ }
1758
+ }
1759
+
1760
+ function distinct(values) {
1761
+ return [...new Set(values)];
1762
+ }
1763
+
1764
+ function findLegacyPlayersPath(top) {
1765
+ const playbooks = isObject(top?.playbooks) ? top.playbooks : {};
1766
+ for (const [id, block] of Object.entries(playbooks)) {
1767
+ if (isObject(block) && hasOwn(block, 'players')) {
1768
+ return `playbooks.${id}.players`;
1769
+ }
1770
+ }
1771
+ return undefined;
1772
+ }
1773
+
1774
+ function legacyPlayersError(path, configPath) {
1775
+ const where = configPath ? ` in ${configPath}` : '';
1776
+ const error = new Error(
1777
+ `${path} was removed in the explicit-session-player major release${where}: ` +
1778
+ 'define stable ids in top-level players and bind them explicitly under ' +
1779
+ 'playbooks.<id>.roles; automatic migration would choose which prior ' +
1780
+ 'conversations share a session',
1781
+ );
1782
+ error.code = 'PLAYBOOK_LEGACY_PLAYERS';
1783
+ error.legacyPath = path;
1784
+ return error;
829
1785
  }
830
1786
 
831
1787
  function cloneJson(value, path, seen = new Set()) {
@@ -918,6 +1874,12 @@ function isObject(value) {
918
1874
  return typeof value === 'object' && value !== null && !Array.isArray(value);
919
1875
  }
920
1876
 
1877
+ function isPlainObject(value) {
1878
+ if (!isObject(value)) return false;
1879
+ const prototype = Object.getPrototypeOf(value);
1880
+ return prototype === Object.prototype || prototype === null;
1881
+ }
1882
+
921
1883
  function hasOwn(value, key) {
922
1884
  return Object.prototype.hasOwnProperty.call(value, key);
923
1885
  }