@sublang/playbook 6.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 (63) hide show
  1. package/README.md +28 -11
  2. package/docs/cli.md +158 -68
  3. package/docs/configuration.md +246 -108
  4. package/docs/embedding.md +71 -25
  5. package/package.json +6 -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 +1900 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +573 -535
  12. package/reference/sdlc/code.playbook/bin/provision.js +84 -38
  13. package/reference/sdlc/code.playbook/bin/run.js +1164 -991
  14. package/reference/sdlc/code.playbook/bin/session-store.js +1961 -0
  15. package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  18. package/reference/sdlc/code.playbook/code.fsm.js +7 -11
  19. package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
  20. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  21. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  22. package/reference/sdlc/code.playbook/code.playbook.js +12 -13
  23. package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
  24. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  25. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  26. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  27. package/reference/sdlc/code.playbook/playbook-captain.d.ts +101 -9
  28. package/reference/sdlc/code.playbook/playbook-captain.js +1690 -213
  29. package/reference/sdlc/code.playbook/playbook-captain.ts +2492 -253
  30. package/reference/sdlc/code.playbook/playbook.config.template.yaml +44 -62
  31. package/reference/sdlc/decide.md +4 -4
  32. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
  33. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  34. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  35. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  36. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
  37. package/reference/sdlc/decide.playbook/decide.playbook.js +244 -143
  38. package/reference/sdlc/decide.playbook/decide.playbook.ts +326 -171
  39. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  40. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  41. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  42. package/reference/sdlc/review.md +4 -5
  43. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  44. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  45. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  46. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  47. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  48. package/reference/sdlc/review.playbook/review.playbook.js +16 -21
  49. package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
  50. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  51. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  52. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  53. package/slc/gears2fsm.md +27 -23
  54. package/slc/link.md +140 -97
  55. package/slc/text2gears.md +19 -18
  56. package/src/runtime.d.ts +24 -8
  57. package/src/runtime.ts +29 -13
  58. package/src/xstate-playbook-runtime.d.ts +21 -17
  59. package/src/xstate-playbook-runtime.js +301 -159
  60. package/src/xstate-playbook-runtime.ts +405 -186
  61. package/src/xstate-runtime.d.ts +19 -2
  62. package/src/xstate-runtime.js +403 -62
  63. package/src/xstate-runtime.ts +566 -78
@@ -0,0 +1,1900 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ // PBCLI-46: this module owns the host-neutral launch-configuration path used
5
+ // by both presentation front ends. It deliberately contains no tmux process
6
+ // control and stores no imported registry functions in the normalized plan.
7
+
8
+ import {
9
+ constants,
10
+ copyFileSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ readFileSync,
15
+ rmSync,
16
+ writeFileSync,
17
+ } from 'node:fs';
18
+ import { homedir, tmpdir } from 'node:os';
19
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
20
+ import { fileURLToPath, pathToFileURL } from 'node:url';
21
+ import { isDeepStrictEqual } from 'node:util';
22
+ import {
23
+ parse as parseYaml,
24
+ parseDocument as parseYamlDocument,
25
+ stringify as stringifyYaml,
26
+ } from 'yaml';
27
+ import { loadTmuxPlayConfig } from '@sublang/cligent/tmux-play';
28
+
29
+ const here = dirname(fileURLToPath(import.meta.url));
30
+ const DEFAULT_TEMPLATE_PATH = resolve(
31
+ here,
32
+ '..',
33
+ 'playbook.config.template.yaml',
34
+ );
35
+
36
+ // PBCLI-1/8: the tmux projection uses the Playbook Captain shell adapter.
37
+ export const PLAYBOOK_CAPTAIN_MODULE =
38
+ '@sublang/playbook/playbook-captain';
39
+ const PLAYBOOK_LAUNCHER_KEYS = ['from', 'command', 'roles'];
40
+ const PLAYBOOK_TOP_LEVEL_KEYS = new Set([
41
+ 'captain',
42
+ 'players',
43
+ 'playbooks',
44
+ 'layout',
45
+ 'notifications',
46
+ 'theme',
47
+ ]);
48
+ const RESERVED_CAPTAIN_PLAYBOOK_ID = 'captain';
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_-]*$/;
52
+
53
+ // PBCLI-26: split ordered `--with <path>` pairs out of an argument vector.
54
+ // The returned arrays are new values; the caller's vector is never changed.
55
+ export function extractWithFlags(argv) {
56
+ const withPaths = [];
57
+ const rest = [];
58
+ for (let i = 0; i < argv.length; i += 1) {
59
+ const arg = argv[i];
60
+ if (arg === '--with') {
61
+ const value = argv[i + 1];
62
+ if (value === undefined || value === '') {
63
+ throw new Error('--with needs a value');
64
+ }
65
+ withPaths.push(value);
66
+ i += 1;
67
+ } else if (arg.startsWith('--with=')) {
68
+ const value = arg.slice('--with='.length);
69
+ if (!value) throw new Error('--with needs a value');
70
+ withPaths.push(value);
71
+ } else {
72
+ rest.push(arg);
73
+ }
74
+ }
75
+ return { withPaths, rest };
76
+ }
77
+
78
+ // PBCLI-25: an overlay fragment is a top-level-format YAML map.
79
+ export function loadOverlayFragment(overlayPath) {
80
+ const resolved = resolve(overlayPath);
81
+ let text;
82
+ try {
83
+ text = readFileSync(resolved, 'utf8');
84
+ } catch (error) {
85
+ throw new Error(
86
+ `cannot read --with overlay ${overlayPath}: ${errorMessage(error)}`,
87
+ );
88
+ }
89
+ let fragment;
90
+ try {
91
+ fragment = parseYaml(text);
92
+ } catch (error) {
93
+ throw new Error(
94
+ `cannot parse --with overlay ${overlayPath}: ${errorMessage(error)}`,
95
+ );
96
+ }
97
+ if (!isObject(fragment)) {
98
+ throw new Error(`--with overlay ${overlayPath} must be a YAML map`);
99
+ }
100
+ return fragment;
101
+ }
102
+
103
+ // PBCLI-25/46: recursively merge maps and replace every other value without
104
+ // mutating either input. Object.fromEntries also makes __proto__ a data key.
105
+ export function mergeConfigs(base, overlay) {
106
+ return Object.fromEntries([
107
+ ...Object.entries(base),
108
+ ...Object.entries(overlay).map(([key, value]) => [
109
+ key,
110
+ isObject(base[key]) && isObject(value)
111
+ ? mergeConfigs(base[key], value)
112
+ : value,
113
+ ]),
114
+ ]);
115
+ }
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
+
129
+ export function resolveConfigHome(env = process.env, home = homedir()) {
130
+ return env.XDG_CONFIG_HOME || join(home, '.config');
131
+ }
132
+
133
+ export function resolveUserConfigPath(env = process.env, home = homedir()) {
134
+ return join(resolveConfigHome(env, home), 'playbook', 'playbook.config.yaml');
135
+ }
136
+
137
+ // PBCLI-46: configured filesystem modules are anchored once to the primary
138
+ // config, including paths introduced by overlays. Bare/custom specifiers and
139
+ // already-authored file URLs retain their module semantics.
140
+ export function canonicalizeRegistrySpecifier(from, configPath) {
141
+ if (configPath === undefined || from.startsWith('file:')) return from;
142
+ if (
143
+ isAbsolute(from) ||
144
+ from.startsWith('./') ||
145
+ from.startsWith('../') ||
146
+ from.startsWith('.\\') ||
147
+ from.startsWith('..\\')
148
+ ) {
149
+ return pathToFileURL(resolve(dirname(configPath), from)).href;
150
+ }
151
+ return from;
152
+ }
153
+
154
+ // PBCLI-46: seed, migrate, overlay, validate, and normalize through one path.
155
+ // `prepareRegistryModule` is the single provision-before-import seam used by
156
+ // both front ends, so filesystem registry handling cannot drift by presenter.
157
+ export async function loadLaunchPlan({
158
+ userConfigPath,
159
+ overlayPaths = [],
160
+ loadModule = (specifier) => import(specifier),
161
+ prepareRegistryModule,
162
+ templatePath = DEFAULT_TEMPLATE_PATH,
163
+ onNotice = () => {},
164
+ selectedMembers,
165
+ }) {
166
+ seedUserConfigIfMissing(userConfigPath, templatePath, 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
+ }
172
+
173
+ let top = parseYaml(readFileSync(userConfigPath, 'utf8')) ?? {};
174
+ if (overlayPaths.length > 0 && !isObject(top)) {
175
+ throw new Error(
176
+ `the top-level config at ${userConfigPath} must be a YAML map before --with can overlay it`,
177
+ );
178
+ }
179
+ for (const overlayPath of overlayPaths) {
180
+ const overlay = loadOverlayFragment(overlayPath);
181
+ top =
182
+ selectedMembers === undefined
183
+ ? mergeConfigs(top, overlay)
184
+ : mergeSelectedConfigs(top, overlay, selectedMembers);
185
+ }
186
+ return await normalizeLaunchPlan(top, {
187
+ loadModule,
188
+ configPath: userConfigPath,
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,
232
+ });
233
+ }
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
+
502
+ // PBCLI-8 (DR-021): scalar agents are adapter shorthands and full blocks
503
+ // carry their own settings without profile indirection.
504
+ export function resolveAgent(value, path, reservedKeys = []) {
505
+ if (typeof value === 'string') {
506
+ if (value.trim().length === 0) {
507
+ throw new Error(`${path} must name an adapter`);
508
+ }
509
+ return { adapter: value };
510
+ }
511
+ if (isObject(value)) {
512
+ for (const key of reservedKeys) {
513
+ if (hasOwn(value, key)) {
514
+ throw new Error(`${path}.${key} is launcher-owned`);
515
+ }
516
+ }
517
+ return cloneJson(value, path);
518
+ }
519
+ throw new Error(`${path} must be an adapter shorthand or an agent block`);
520
+ }
521
+
522
+ // PBCLI-46: normalize into a detached, deeply frozen JSON plan. The plan has
523
+ // only execution data and presentation data; imported registry functions are
524
+ // consulted for validation and then discarded.
525
+ export async function normalizeLaunchPlan(
526
+ top,
527
+ { loadModule, configPath, prepareRegistryModule, selectedMembers } = {},
528
+ ) {
529
+ const importModule = loadModule ?? ((specifier) => import(specifier));
530
+ top = projectSelectedMembers(top, selectedMembers);
531
+ top = cloneJson(top, 'config');
532
+ assertNoRetiredProfiles(top, configPath);
533
+ if (hasOwn(top, 'run')) {
534
+ throw new Error(
535
+ 'top-level "run" was removed: configure the shared Captain under ' +
536
+ 'captain, top-level players, and playbooks.<id>.roles instead',
537
+ );
538
+ }
539
+ const unknownTopLevel = Object.keys(top).filter(
540
+ (key) => !PLAYBOOK_TOP_LEVEL_KEYS.has(key),
541
+ );
542
+ if (unknownTopLevel.length > 0) {
543
+ throw new Error(
544
+ `config has unknown top-level ${formatKeyList(unknownTopLevel)}`,
545
+ );
546
+ }
547
+ if (top.layout !== undefined && !isObject(top.layout)) {
548
+ throw new Error('layout must be a map');
549
+ }
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
+
568
+ const playbooksCfg = requireObject(top.playbooks, 'playbooks');
569
+ const ids = Object.keys(playbooksCfg);
570
+ if (ids.length === 0) {
571
+ throw new Error('playbooks must enable at least one playbook');
572
+ }
573
+ if (ids.some((id) => id.trim().length === 0 || id !== id.trim())) {
574
+ throw new Error('playbooks keys must be canonical trimmed nonblank ids');
575
+ }
576
+
577
+ let captain = resolveAgent(top.captain, 'captain', ['from', 'options']);
578
+ if (
579
+ typeof captain.adapter !== 'string' ||
580
+ captain.adapter.trim().length === 0
581
+ ) {
582
+ throw new Error('captain must resolve an adapter');
583
+ }
584
+
585
+ // Validate and detach every retained config-owned value before provisioning
586
+ // or importing any registry. That keeps malformed config side-effect free.
587
+ const configuredPlaybooks = [];
588
+ const tuningChecks = [];
589
+ let tuningCheckIndex = 0;
590
+ for (const id of ids) {
591
+ if (id === RESERVED_CAPTAIN_PLAYBOOK_ID) {
592
+ throw new Error(
593
+ `playbooks.${id} collides with the reserved internal Captain id`,
594
+ );
595
+ }
596
+ const block = requireObject(playbooksCfg[id], `playbooks.${id}`);
597
+ if (hasOwn(block, 'players')) {
598
+ throw legacyPlayersError(`playbooks.${id}.players`, configPath);
599
+ }
600
+ const from = block.from;
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
+ );
609
+ }
610
+ if (
611
+ block.command !== undefined &&
612
+ (typeof block.command !== 'string' ||
613
+ block.command.trim().length === 0 ||
614
+ block.command !== block.command.trim())
615
+ ) {
616
+ throw new Error(
617
+ `playbooks.${id}.command must be a canonical trimmed nonblank string`,
618
+ );
619
+ }
620
+ if (block.command === RESERVED_CAPTAIN_PLAYBOOK_ID) {
621
+ throw new Error(
622
+ `playbooks.${id}.command collides with the reserved internal Captain command`,
623
+ );
624
+ }
625
+ const rolesMap = requireObject(block.roles, `playbooks.${id}.roles`);
626
+ const roles = Object.keys(rolesMap);
627
+ if (roles.some((role) => role.trim().length === 0)) {
628
+ throw new Error(`playbooks.${id}.roles keys must be nonblank role ids`);
629
+ }
630
+ if (roles.includes(RESERVED_CAPTAIN_ROLE_ID)) {
631
+ throw new Error(
632
+ `playbooks.${id}.roles.${RESERVED_CAPTAIN_ROLE_ID} binds local ` +
633
+ `role "${RESERVED_CAPTAIN_ROLE_ID}", which is reserved for the ` +
634
+ 'tmux-play Captain',
635
+ );
636
+ }
637
+ const bindings = Object.create(null);
638
+ for (const role of roles) {
639
+ assertRoleId(role, `playbooks.${id}.roles.${role}`);
640
+ const binding = resolveRoleBinding(
641
+ rolesMap[role],
642
+ `playbooks.${id}.roles.${role}`,
643
+ );
644
+ const agent = configuredAgents.get(binding.playerId);
645
+ if (agent === undefined) {
646
+ throw new Error(
647
+ `playbooks.${id}.roles.${role} names unknown player ` +
648
+ JSON.stringify(binding.playerId),
649
+ );
650
+ }
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
+ });
662
+ }
663
+ }
664
+ const optionSlice = Object.fromEntries(
665
+ Object.entries(block).filter(
666
+ ([key]) => !PLAYBOOK_LAUNCHER_KEYS.includes(key),
667
+ ),
668
+ );
669
+ const configuredFrom = canonicalizeRegistrySpecifier(from, configPath);
670
+ configuredPlaybooks.push({
671
+ id,
672
+ from,
673
+ configuredFrom,
674
+ commandOverride: block.command,
675
+ roles,
676
+ bindings,
677
+ optionSlice,
678
+ });
679
+ }
680
+
681
+ // Both front ends consume the exact installed cligent schema. Normalize a
682
+ // detached provisional projection before any registry preparation/import,
683
+ // then feed its agent and presentation fields back into the authoritative
684
+ // plan. The interactive child will only revalidate these same values.
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;
703
+ const provisional = {
704
+ captain: {
705
+ ...captain,
706
+ from: PLAYBOOK_CAPTAIN_MODULE,
707
+ options: {},
708
+ },
709
+ players: [
710
+ ...allPlayerIds.map((id) => ({
711
+ id,
712
+ ...configuredAgents.get(id),
713
+ })),
714
+ ...tuningChecks.map(({ id, agent }) => ({ id, ...agent })),
715
+ ],
716
+ layout: {
717
+ ...(isObject(top.layout) ? top.layout : {}),
718
+ initialVisible: validationVisible,
719
+ },
720
+ ...(top.notifications === undefined
721
+ ? {}
722
+ : { notifications: top.notifications }),
723
+ ...(top.theme === undefined ? {} : { theme: top.theme }),
724
+ };
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;
738
+ const { from: _captainFrom, options: _captainOptions, ...normalizedCaptain } =
739
+ normalizedHost.captain;
740
+ captain = sessionAgentFromHostAgent(normalizedCaptain, 'captain');
741
+ const hostAgents = new Map(
742
+ normalizedHost.players.map(({ id, ...agent }) => [id, agent]),
743
+ );
744
+ const normalizedPlayerAgents = new Map(
745
+ allPlayerIds.map((id) => [
746
+ id,
747
+ sessionAgentFromHostAgent(hostAgents.get(id), `players.${id}`),
748
+ ]),
749
+ );
750
+
751
+ // Preparation is a transaction-like pre-import phase across the complete
752
+ // configured catalog. A provisioning failure therefore cannot leave some
753
+ // registry modules evaluated and others untouched.
754
+ const preparedPlaybooks = [];
755
+ for (const configured of configuredPlaybooks) {
756
+ const { id, from, configuredFrom } = configured;
757
+ let preparedFrom = configuredFrom;
758
+ if (prepareRegistryModule !== undefined) {
759
+ try {
760
+ const prepared = await prepareRegistryModule({
761
+ id,
762
+ from: configuredFrom,
763
+ authoredFrom: from,
764
+ configPath,
765
+ });
766
+ if (prepared !== undefined) preparedFrom = prepared;
767
+ } catch (cause) {
768
+ throw new Error(
769
+ `playbooks.${id}.from "${from}" failed to prepare: ${errorMessage(cause)}`,
770
+ );
771
+ }
772
+ if (
773
+ typeof preparedFrom !== 'string' ||
774
+ preparedFrom.trim().length === 0
775
+ ) {
776
+ throw new Error(
777
+ `playbooks.${id}.from "${from}" preparation must return a module specifier`,
778
+ );
779
+ }
780
+ }
781
+ preparedFrom = canonicalizePreparedRegistrySpecifier(
782
+ preparedFrom,
783
+ `playbooks.${id}.from`,
784
+ );
785
+ preparedPlaybooks.push({ ...configured, preparedFrom });
786
+ }
787
+
788
+ const catalogEntries = [];
789
+ const seenCommands = new Map();
790
+ const seenIds = new Set();
791
+
792
+ for (const {
793
+ id,
794
+ from,
795
+ preparedFrom,
796
+ commandOverride,
797
+ roles,
798
+ bindings,
799
+ optionSlice,
800
+ } of preparedPlaybooks) {
801
+ let mod;
802
+ try {
803
+ mod = await importModule(preparedFrom);
804
+ } catch (cause) {
805
+ throw new Error(
806
+ `playbooks.${id}.from "${from}" failed to import: ${errorMessage(cause)}`,
807
+ );
808
+ }
809
+ const entry = mod?.default;
810
+ const registryProblem = invalidRegistryEntryReason(entry);
811
+ if (registryProblem !== undefined) {
812
+ throw new Error(
813
+ `playbooks.${id}.from "${from}" exposes no valid registry entry: ` +
814
+ registryProblem,
815
+ );
816
+ }
817
+ if (entry.id !== id) {
818
+ throw new Error(
819
+ `playbooks.${id} key must equal the module manifest id "${entry.id}"`,
820
+ );
821
+ }
822
+ if (seenIds.has(entry.id)) {
823
+ throw new Error(`duplicate playbook id "${entry.id}"`);
824
+ }
825
+ seenIds.add(entry.id);
826
+
827
+ const command = commandOverride ?? entry.command;
828
+ if (command === RESERVED_CAPTAIN_PLAYBOOK_ID) {
829
+ throw new Error(
830
+ `playbooks.${id}.command collides with the reserved internal Captain command`,
831
+ );
832
+ }
833
+ if (seenCommands.has(command)) {
834
+ throw new Error(`duplicate effective command "${command}"`);
835
+ }
836
+ seenCommands.set(command, id);
837
+
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);
860
+
861
+ catalogEntries.push([
862
+ id,
863
+ {
864
+ id,
865
+ from: preparedFrom,
866
+ // Keep the registry-authored default even when launcher config
867
+ // overrides the effective command. Durable continuation validates
868
+ // this manifest identity but still executes the frozen command.
869
+ manifestCommand: entry.command,
870
+ command,
871
+ ...(commandOverride === undefined ? {} : { commandOverride }),
872
+ intent: entry.intent,
873
+ artifactSchema: entry.artifactSchema,
874
+ requiredRoleIds: [...entry.requiredRoleIds],
875
+ concurrentRoleSets: entry.concurrentRoleSets.map((set) => [...set]),
876
+ roles: resolvedRoles,
877
+ options: optionSlice,
878
+ },
879
+ ]);
880
+ }
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
+ );
901
+ const presentation = {
902
+ layout: {
903
+ ...normalizedPresentationHost.layout,
904
+ initialVisible,
905
+ },
906
+ notifications: normalizedPresentationHost.notifications,
907
+ ...(normalizedPresentationHost.theme === undefined
908
+ ? {}
909
+ : { theme: normalizedPresentationHost.theme }),
910
+ };
911
+
912
+ return deepFreeze(
913
+ cloneJson(
914
+ {
915
+ schemaVersion: 1,
916
+ captain,
917
+ players: referencedPlayerIds.map((id) => ({
918
+ id,
919
+ agent: normalizedPlayerAgents.get(id),
920
+ })),
921
+ catalog: Object.fromEntries(catalogEntries),
922
+ presentation,
923
+ },
924
+ 'launch config',
925
+ ),
926
+ );
927
+ }
928
+
929
+ // Run cligent's public explicit-config loader over an isolated projection so
930
+ // generic launch planning tracks the installed host schema without importing
931
+ // or duplicating cligent's private validators.
932
+ export async function normalizeHostConfig(config) {
933
+ const dir = mkdtempSync(join(tmpdir(), 'playbook-host-config-'));
934
+ const path = join(dir, 'tmux-play.config.yaml');
935
+ try {
936
+ writeFileSync(path, stringifyYaml(config));
937
+ return (await loadTmuxPlayConfig({ configPath: path })).config;
938
+ } finally {
939
+ rmSync(dir, { recursive: true, force: true });
940
+ }
941
+ }
942
+
943
+ // PBCLI-8/9/10/46: tmux is one projection of the host-neutral plan. The
944
+ // projection is detached so cligent normalization cannot mutate the plan.
945
+ export function projectTmuxConfig(plan) {
946
+ const playbooks = Object.fromEntries(
947
+ Object.entries(plan.catalog).map(([id, item]) => [
948
+ id,
949
+ {
950
+ from: item.from,
951
+ command: item.command,
952
+ roles: cloneJson(item.roles, `catalog.${id}.roles`),
953
+ options: cloneJson(item.options, `catalog.${id}.options`),
954
+ },
955
+ ]),
956
+ );
957
+ const captain = {
958
+ ...projectHostAgent(plan.captain, 'captain'),
959
+ from: PLAYBOOK_CAPTAIN_MODULE,
960
+ };
961
+ captain.options = {
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
+ },
972
+ ...(typeof captain.adapter === 'string' && captain.adapter.length > 0
973
+ ? { captainAdapter: captain.adapter }
974
+ : {}),
975
+ };
976
+ const config = {
977
+ captain,
978
+ players: plan.players.map(({ id, agent }) => ({
979
+ ...projectHostAgent(agent, `players.${id}.agent`),
980
+ id,
981
+ })),
982
+ layout: projectHostLayout(plan.presentation.layout),
983
+ };
984
+ if (hasOwn(plan.presentation, 'notifications')) {
985
+ config.notifications = cloneJson(
986
+ plan.presentation.notifications,
987
+ 'presentation.notifications',
988
+ );
989
+ }
990
+ if (hasOwn(plan.presentation, 'theme')) {
991
+ config.theme = cloneJson(plan.presentation.theme, 'presentation.theme');
992
+ }
993
+ return config;
994
+ }
995
+
996
+ function projectHostLayout(layout) {
997
+ const projected = cloneJson(layout, 'presentation.layout');
998
+ // cligent's normalized runtime shape carries `columnWeights` as the
999
+ // derived active-shape value alongside both canonical shape fields. The
1000
+ // authored schema deliberately rejects that alias/canonical combination,
1001
+ // so the interactive child projection must serialize canonical authority
1002
+ // only; its loader deterministically derives the same alias again.
1003
+ if (
1004
+ Array.isArray(projected.singlePlayerColumnWeights) &&
1005
+ Array.isArray(projected.multiPlayerColumnWeights)
1006
+ ) {
1007
+ delete projected.columnWeights;
1008
+ }
1009
+ return projected;
1010
+ }
1011
+
1012
+ // Compatibility surface used by existing integrations and tests. New hosts
1013
+ // consume normalizeLaunchPlan/loadLaunchPlan and choose their projection.
1014
+ export async function composeGenericConfig(top, loadModule, configPath) {
1015
+ const plan = await normalizeLaunchPlan(top, { loadModule, configPath });
1016
+ return {
1017
+ config: projectTmuxConfig(plan),
1018
+ playbooks: Object.values(plan.catalog).map(({ id, command, intent }) => ({
1019
+ id,
1020
+ command,
1021
+ intent,
1022
+ })),
1023
+ };
1024
+ }
1025
+
1026
+ export function adaptersFromLaunchPlan(plan) {
1027
+ const adapters = new Set();
1028
+ if (plan?.captain?.adapter) adapters.add(plan.captain.adapter);
1029
+ for (const player of plan?.players ?? []) {
1030
+ if (player?.agent?.adapter) adapters.add(player.agent.adapter);
1031
+ }
1032
+ return [...adapters];
1033
+ }
1034
+
1035
+ export function adaptersFromComposedConfig(config) {
1036
+ const adapters = new Set();
1037
+ if (config?.captain?.adapter) adapters.add(config.captain.adapter);
1038
+ for (const player of config?.players ?? []) {
1039
+ if (player?.adapter) adapters.add(player.adapter);
1040
+ }
1041
+ return [...adapters];
1042
+ }
1043
+
1044
+ export function checkReadiness(adapters, env = process.env, home = homedir()) {
1045
+ const failingAdapters = [];
1046
+ const unknownAdapters = [];
1047
+ for (const adapter of adapters) {
1048
+ if (adapter === 'claude') {
1049
+ if (!env.ANTHROPIC_API_KEY && !existsSync(join(home, '.claude'))) {
1050
+ failingAdapters.push(adapter);
1051
+ }
1052
+ continue;
1053
+ }
1054
+ if (adapter === 'codex') {
1055
+ if (!env.OPENAI_API_KEY && !existsSync(join(home, '.codex'))) {
1056
+ failingAdapters.push(adapter);
1057
+ }
1058
+ continue;
1059
+ }
1060
+ unknownAdapters.push(adapter);
1061
+ }
1062
+ return { failingAdapters, unknownAdapters };
1063
+ }
1064
+
1065
+ export function deriveLaunchReadiness(
1066
+ plan,
1067
+ env = process.env,
1068
+ home = homedir(),
1069
+ ) {
1070
+ const adapters = adaptersFromLaunchPlan(plan);
1071
+ return { adapters, ...checkReadiness(adapters, env, home) };
1072
+ }
1073
+
1074
+ function seedUserConfigIfMissing(userConfigPath, templatePath, onNotice) {
1075
+ if (existsSync(userConfigPath)) return;
1076
+ mkdirSync(dirname(userConfigPath), { recursive: true });
1077
+ copyFileSync(templatePath, userConfigPath, constants.COPYFILE_EXCL);
1078
+ onNotice(`playbook: created config at ${userConfigPath}\n`);
1079
+ }
1080
+
1081
+ // DR-021 §3: migrate once, keeping the original before any rewrite.
1082
+ function migrateUserConfigIfRetired(userConfigPath, onNotice) {
1083
+ let text;
1084
+ try {
1085
+ text = readFileSync(userConfigPath, 'utf8');
1086
+ } catch {
1087
+ return;
1088
+ }
1089
+ let migrated;
1090
+ try {
1091
+ migrated = migrateRetiredProfiles(text);
1092
+ } catch (error) {
1093
+ if (error?.code === 'PLAYBOOK_LEGACY_PLAYERS') {
1094
+ throw legacyPlayersError(error.legacyPath, userConfigPath);
1095
+ }
1096
+ throw new Error(
1097
+ `cannot migrate the retired profiles config at ${userConfigPath}: ` +
1098
+ `${errorMessage(error)} — edit it by hand: each agent takes its own ` +
1099
+ 'adapter, model, effort, and permissions',
1100
+ );
1101
+ }
1102
+ if (migrated === undefined) return;
1103
+ const backupPath = freeBackupPath(userConfigPath);
1104
+ writeFileSync(backupPath, text, { mode: 0o600 });
1105
+ writeFileSync(userConfigPath, migrated);
1106
+ onNotice(
1107
+ `playbook: migrated ${userConfigPath} to inline agent settings ` +
1108
+ `(the top-level "profiles" map was removed in 3.0.0); ` +
1109
+ `the original is at ${backupPath}\n`,
1110
+ );
1111
+ }
1112
+
1113
+ function freeBackupPath(userConfigPath) {
1114
+ const first = `${userConfigPath}.bak`;
1115
+ if (!existsSync(first)) return first;
1116
+ for (let n = 2; ; n += 1) {
1117
+ const candidate = `${userConfigPath}.bak.${n}`;
1118
+ if (!existsSync(candidate)) return candidate;
1119
+ }
1120
+ }
1121
+
1122
+ // DR-021 §3: rewrite through YAML's Document API so comments survive.
1123
+ export function migrateRetiredProfiles(text) {
1124
+ const doc = parseYamlDocument(text);
1125
+ const contents = doc.contents;
1126
+ if (!contents || !Array.isArray(contents.items)) return undefined;
1127
+ const playbooks = doc.get('playbooks');
1128
+ if (playbooks && Array.isArray(playbooks.items)) {
1129
+ for (const entry of playbooks.items) {
1130
+ const id = String(entry.key);
1131
+ if (doc.getIn(['playbooks', id, 'players']) !== undefined) {
1132
+ throw legacyPlayersError(`playbooks.${id}.players`);
1133
+ }
1134
+ }
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
+ }
1144
+
1145
+ const profileSettings = (name) =>
1146
+ profiles && typeof profiles.get === 'function'
1147
+ ? profiles.get(name)
1148
+ : undefined;
1149
+
1150
+ let changed = false;
1151
+ for (const path of agentPaths) {
1152
+ const node = doc.getIn(path, true);
1153
+ if (node && typeof node.value === 'string' && !Array.isArray(node.items)) {
1154
+ const settings = profileSettings(node.value);
1155
+ if (settings === undefined) continue;
1156
+ const inlined = settings.clone();
1157
+ carryScalarComment(node, inlined);
1158
+ doc.setIn(path, inlined);
1159
+ changed = true;
1160
+ } else if (node && Array.isArray(node.items)) {
1161
+ const named = node.get?.('profile');
1162
+ if (named === undefined) continue;
1163
+ const settings = profileSettings(named);
1164
+ if (settings === undefined) {
1165
+ throw new Error(
1166
+ `${path.join('.')}.profile names "${String(named)}", which no ` +
1167
+ 'profiles entry defines',
1168
+ );
1169
+ }
1170
+ node.delete('profile');
1171
+ for (const item of settings.items) {
1172
+ if (node.has(String(item.key))) continue;
1173
+ node.add(item.clone());
1174
+ }
1175
+ changed = true;
1176
+ }
1177
+ }
1178
+
1179
+ if (profiles !== undefined) {
1180
+ const index = contents.items.findIndex(
1181
+ (item) => String(item.key) === 'profiles',
1182
+ );
1183
+ const lead =
1184
+ index === -1 ? undefined : contents.items[index]?.key?.commentBefore;
1185
+ doc.delete('profiles');
1186
+ const header = keptHeaderComment(lead);
1187
+ const next = contents.items[0];
1188
+ if (header !== undefined && next?.key) {
1189
+ next.key.commentBefore =
1190
+ next.key.commentBefore === undefined
1191
+ ? header
1192
+ : `${header}\n\n${next.key.commentBefore}`;
1193
+ }
1194
+ changed = true;
1195
+ }
1196
+ if (!changed) return undefined;
1197
+ doc.commentBefore = MIGRATION_NOTE;
1198
+ return doc.toString();
1199
+ }
1200
+
1201
+ const MIGRATION_NOTE =
1202
+ ' Migrated by playbook 3.0.0: the top-level `profiles` map was removed and\n' +
1203
+ ' each agent now carries its settings inline. The pre-migration file is\n' +
1204
+ ' kept beside this one as a .bak. Comments below may still describe the\n' +
1205
+ ' retired profiles model.';
1206
+
1207
+ function carryScalarComment(node, inlined) {
1208
+ const parts = [node.commentBefore, node.comment].filter(
1209
+ (part) => typeof part === 'string' && part.trim() !== '',
1210
+ );
1211
+ if (parts.length === 0) return;
1212
+ const first = inlined.items?.[0]?.key;
1213
+ if (!first) return;
1214
+ inlined.flow = false;
1215
+ const carried = parts.join('\n');
1216
+ first.commentBefore =
1217
+ first.commentBefore === undefined
1218
+ ? carried
1219
+ : `${carried}\n${first.commentBefore}`;
1220
+ }
1221
+
1222
+ function keptHeaderComment(comment) {
1223
+ if (typeof comment !== 'string' || comment.trim() === '') return undefined;
1224
+ const paragraphs = comment.split('\n\n');
1225
+ const kept = paragraphs.slice(0, -1).join('\n\n');
1226
+ return kept.trim() === '' ? undefined : kept;
1227
+ }
1228
+
1229
+ function assertNoRetiredProfiles(top, configPath) {
1230
+ const where = configPath ? ` in ${configPath}` : '';
1231
+ if (top.profiles !== undefined) {
1232
+ throw new Error(
1233
+ `top-level "profiles" was removed${where}: write each agent's settings ` +
1234
+ 'inline under captain and each top-level players.<player-id> ' +
1235
+ '(adapter, model, effort, permissions)',
1236
+ );
1237
+ }
1238
+ const legacyPath = findLegacyPlayersPath(top);
1239
+ if (legacyPath !== undefined) throw legacyPlayersError(legacyPath, configPath);
1240
+ const blocks = [['captain', top.captain]];
1241
+ const playersCfg = isObject(top.players) ? top.players : {};
1242
+ for (const [playerId, agent] of Object.entries(playersCfg)) {
1243
+ blocks.push([`players.${playerId}`, agent]);
1244
+ }
1245
+ for (const [path, block] of blocks) {
1246
+ if (isObject(block) && block.profile !== undefined) {
1247
+ throw new Error(
1248
+ `${path}.profile was removed${where}: write the agent's settings ` +
1249
+ 'inline in that block (adapter, model, effort, permissions)',
1250
+ );
1251
+ }
1252
+ }
1253
+ }
1254
+
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',
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;
1785
+ }
1786
+
1787
+ function cloneJson(value, path, seen = new Set()) {
1788
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
1789
+ return value;
1790
+ }
1791
+ if (typeof value === 'number') {
1792
+ if (Number.isFinite(value)) return value;
1793
+ throw new Error(`${path} must contain only finite JSON numbers`);
1794
+ }
1795
+ if (typeof value !== 'object') {
1796
+ throw new Error(`${path} must contain only JSON values`);
1797
+ }
1798
+ if (seen.has(value)) throw new Error(`${path} must not contain a cycle`);
1799
+ seen.add(value);
1800
+ try {
1801
+ if (Array.isArray(value)) {
1802
+ const keys = Reflect.ownKeys(value);
1803
+ if (
1804
+ keys.some(
1805
+ (key) =>
1806
+ typeof key === 'symbol' ||
1807
+ (key !== 'length' &&
1808
+ (!/^(?:0|[1-9]\d*)$/.test(key) || Number(key) >= value.length)),
1809
+ )
1810
+ ) {
1811
+ throw new Error(`${path} must be a plain JSON array`);
1812
+ }
1813
+ const descriptors = Object.getOwnPropertyDescriptors(value);
1814
+ const cloned = [];
1815
+ for (let index = 0; index < value.length; index += 1) {
1816
+ const descriptor = descriptors[index];
1817
+ if (descriptor === undefined) {
1818
+ throw new Error(`${path} must not contain sparse array slots`);
1819
+ }
1820
+ if (
1821
+ descriptor.get !== undefined ||
1822
+ descriptor.set !== undefined ||
1823
+ descriptor.enumerable !== true
1824
+ ) {
1825
+ throw new Error(
1826
+ `${path}[${index}] must be an enumerable data property`,
1827
+ );
1828
+ }
1829
+ cloned.push(
1830
+ cloneJson(descriptor.value, `${path}[${index}]`, seen),
1831
+ );
1832
+ }
1833
+ return cloned;
1834
+ }
1835
+ const prototype = Object.getPrototypeOf(value);
1836
+ if (prototype !== Object.prototype && prototype !== null) {
1837
+ throw new Error(`${path} must contain only plain JSON objects`);
1838
+ }
1839
+ const keys = Reflect.ownKeys(value);
1840
+ if (keys.some((key) => typeof key === 'symbol')) {
1841
+ throw new Error(`${path} must not contain symbol keys`);
1842
+ }
1843
+ const descriptors = Object.getOwnPropertyDescriptors(value);
1844
+ for (const key of keys) {
1845
+ const descriptor = descriptors[key];
1846
+ if (
1847
+ descriptor?.get !== undefined ||
1848
+ descriptor?.set !== undefined ||
1849
+ descriptor?.enumerable !== true
1850
+ ) {
1851
+ throw new Error(`${path}.${key} must be an enumerable data property`);
1852
+ }
1853
+ }
1854
+ return Object.fromEntries(
1855
+ keys.map((key) => [
1856
+ key,
1857
+ cloneJson(descriptors[key].value, `${path}.${key}`, seen),
1858
+ ]),
1859
+ );
1860
+ } finally {
1861
+ seen.delete(value);
1862
+ }
1863
+ }
1864
+
1865
+ function deepFreeze(value) {
1866
+ if (value === null || typeof value !== 'object' || Object.isFrozen(value)) {
1867
+ return value;
1868
+ }
1869
+ for (const child of Object.values(value)) deepFreeze(child);
1870
+ return Object.freeze(value);
1871
+ }
1872
+
1873
+ function isObject(value) {
1874
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1875
+ }
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
+
1883
+ function hasOwn(value, key) {
1884
+ return Object.prototype.hasOwnProperty.call(value, key);
1885
+ }
1886
+
1887
+ function requireObject(value, path) {
1888
+ if (!isObject(value)) throw new Error(`${path} must be an object`);
1889
+ return value;
1890
+ }
1891
+
1892
+ function formatKeyList(keys) {
1893
+ return `${keys.length === 1 ? 'key' : 'keys'} ${keys
1894
+ .map((key) => JSON.stringify(key))
1895
+ .join(', ')}`;
1896
+ }
1897
+
1898
+ function errorMessage(error) {
1899
+ return error instanceof Error ? error.message : String(error);
1900
+ }