@sublang/playbook 6.0.0 → 7.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.
@@ -0,0 +1,938 @@
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 {
22
+ parse as parseYaml,
23
+ parseDocument as parseYamlDocument,
24
+ stringify as stringifyYaml,
25
+ } from 'yaml';
26
+ import { loadTmuxPlayConfig } from '@sublang/cligent/tmux-play';
27
+
28
+ const here = dirname(fileURLToPath(import.meta.url));
29
+ const DEFAULT_TEMPLATE_PATH = resolve(
30
+ here,
31
+ '..',
32
+ 'playbook.config.template.yaml',
33
+ );
34
+
35
+ // PBCLI-1/8: the tmux projection uses the Playbook Captain shell adapter.
36
+ export const PLAYBOOK_CAPTAIN_MODULE =
37
+ '@sublang/playbook/playbook-captain';
38
+ const PLAYBOOK_LAUNCHER_KEYS = ['from', 'command', 'players'];
39
+ const PLAYBOOK_TOP_LEVEL_KEYS = new Set([
40
+ 'captain',
41
+ 'playbooks',
42
+ 'layout',
43
+ 'notifications',
44
+ 'theme',
45
+ ]);
46
+ const RESERVED_CAPTAIN_PLAYBOOK_ID = 'captain';
47
+ const RESERVED_CAPTAIN_ROLE_ID = 'captain';
48
+
49
+ // PBCLI-26: split ordered `--with <path>` pairs out of an argument vector.
50
+ // The returned arrays are new values; the caller's vector is never changed.
51
+ export function extractWithFlags(argv) {
52
+ const withPaths = [];
53
+ const rest = [];
54
+ for (let i = 0; i < argv.length; i += 1) {
55
+ const arg = argv[i];
56
+ if (arg === '--with') {
57
+ const value = argv[i + 1];
58
+ if (value === undefined || value === '') {
59
+ throw new Error('--with needs a value');
60
+ }
61
+ withPaths.push(value);
62
+ i += 1;
63
+ } else if (arg.startsWith('--with=')) {
64
+ const value = arg.slice('--with='.length);
65
+ if (!value) throw new Error('--with needs a value');
66
+ withPaths.push(value);
67
+ } else {
68
+ rest.push(arg);
69
+ }
70
+ }
71
+ return { withPaths, rest };
72
+ }
73
+
74
+ // PBCLI-25: an overlay fragment is a top-level-format YAML map.
75
+ export function loadOverlayFragment(overlayPath) {
76
+ const resolved = resolve(overlayPath);
77
+ let text;
78
+ try {
79
+ text = readFileSync(resolved, 'utf8');
80
+ } catch (error) {
81
+ throw new Error(
82
+ `cannot read --with overlay ${overlayPath}: ${errorMessage(error)}`,
83
+ );
84
+ }
85
+ let fragment;
86
+ try {
87
+ fragment = parseYaml(text);
88
+ } catch (error) {
89
+ throw new Error(
90
+ `cannot parse --with overlay ${overlayPath}: ${errorMessage(error)}`,
91
+ );
92
+ }
93
+ if (!isObject(fragment)) {
94
+ throw new Error(`--with overlay ${overlayPath} must be a YAML map`);
95
+ }
96
+ return fragment;
97
+ }
98
+
99
+ // PBCLI-25/46: recursively merge maps and replace every other value without
100
+ // mutating either input. Object.fromEntries also makes __proto__ a data key.
101
+ export function mergeConfigs(base, overlay) {
102
+ return Object.fromEntries([
103
+ ...Object.entries(base),
104
+ ...Object.entries(overlay).map(([key, value]) => [
105
+ key,
106
+ isObject(base[key]) && isObject(value)
107
+ ? mergeConfigs(base[key], value)
108
+ : value,
109
+ ]),
110
+ ]);
111
+ }
112
+
113
+ export function resolveConfigHome(env = process.env, home = homedir()) {
114
+ return env.XDG_CONFIG_HOME || join(home, '.config');
115
+ }
116
+
117
+ export function resolveUserConfigPath(env = process.env, home = homedir()) {
118
+ return join(resolveConfigHome(env, home), 'playbook', 'playbook.config.yaml');
119
+ }
120
+
121
+ // PBCLI-46: configured filesystem modules are anchored once to the primary
122
+ // config, including paths introduced by overlays. Bare/custom specifiers and
123
+ // already-authored file URLs retain their module semantics.
124
+ export function canonicalizeRegistrySpecifier(from, configPath) {
125
+ if (configPath === undefined || from.startsWith('file:')) return from;
126
+ if (
127
+ isAbsolute(from) ||
128
+ from.startsWith('./') ||
129
+ from.startsWith('../') ||
130
+ from.startsWith('.\\') ||
131
+ from.startsWith('..\\')
132
+ ) {
133
+ return pathToFileURL(resolve(dirname(configPath), from)).href;
134
+ }
135
+ return from;
136
+ }
137
+
138
+ // PBCLI-46: seed, migrate, overlay, validate, and normalize through one path.
139
+ // `prepareRegistryModule` is the single provision-before-import seam used by
140
+ // both front ends, so filesystem registry handling cannot drift by presenter.
141
+ export async function loadLaunchPlan({
142
+ userConfigPath,
143
+ overlayPaths = [],
144
+ loadModule = (specifier) => import(specifier),
145
+ prepareRegistryModule,
146
+ templatePath = DEFAULT_TEMPLATE_PATH,
147
+ onNotice = () => {},
148
+ }) {
149
+ seedUserConfigIfMissing(userConfigPath, templatePath, onNotice);
150
+ migrateUserConfigIfRetired(userConfigPath, onNotice);
151
+
152
+ let top = parseYaml(readFileSync(userConfigPath, 'utf8')) ?? {};
153
+ if (overlayPaths.length > 0 && !isObject(top)) {
154
+ throw new Error(
155
+ `the top-level config at ${userConfigPath} must be a YAML map before --with can overlay it`,
156
+ );
157
+ }
158
+ for (const overlayPath of overlayPaths) {
159
+ top = mergeConfigs(top, loadOverlayFragment(overlayPath));
160
+ }
161
+ return await normalizeLaunchPlan(top, {
162
+ loadModule,
163
+ configPath: userConfigPath,
164
+ prepareRegistryModule,
165
+ });
166
+ }
167
+
168
+ // PBCLI-8 (DR-021): scalar agents are adapter shorthands and full blocks
169
+ // carry their own settings without profile indirection.
170
+ export function resolveAgent(value, path, reservedKeys = []) {
171
+ if (typeof value === 'string') {
172
+ if (value.trim().length === 0) {
173
+ throw new Error(`${path} must name an adapter`);
174
+ }
175
+ return { adapter: value };
176
+ }
177
+ if (isObject(value)) {
178
+ for (const key of reservedKeys) {
179
+ if (hasOwn(value, key)) {
180
+ throw new Error(`${path}.${key} is launcher-owned`);
181
+ }
182
+ }
183
+ return cloneJson(value, path);
184
+ }
185
+ throw new Error(`${path} must be an adapter shorthand or an agent block`);
186
+ }
187
+
188
+ // PBCLI-46: normalize into a detached, deeply frozen JSON plan. The plan has
189
+ // only execution data and presentation data; imported registry functions are
190
+ // consulted for validation and then discarded.
191
+ export async function normalizeLaunchPlan(
192
+ top,
193
+ { loadModule, configPath, prepareRegistryModule } = {},
194
+ ) {
195
+ const importModule = loadModule ?? ((specifier) => import(specifier));
196
+ top = cloneJson(top, 'config');
197
+ assertNoRetiredProfiles(top, configPath);
198
+ if (hasOwn(top, 'run')) {
199
+ throw new Error(
200
+ 'top-level "run" was removed: configure the shared Captain under ' +
201
+ 'captain and playbooks.<id>.players instead',
202
+ );
203
+ }
204
+ const unknownTopLevel = Object.keys(top).filter(
205
+ (key) => !PLAYBOOK_TOP_LEVEL_KEYS.has(key),
206
+ );
207
+ if (unknownTopLevel.length > 0) {
208
+ throw new Error(
209
+ `config has unknown top-level ${formatKeyList(unknownTopLevel)}`,
210
+ );
211
+ }
212
+ if (top.layout !== undefined && !isObject(top.layout)) {
213
+ throw new Error('layout must be a map');
214
+ }
215
+
216
+ const playbooksCfg = requireObject(top.playbooks, 'playbooks');
217
+ const ids = Object.keys(playbooksCfg);
218
+ if (ids.length === 0) {
219
+ throw new Error('playbooks must enable at least one playbook');
220
+ }
221
+ if (ids.some((id) => id.trim().length === 0)) {
222
+ throw new Error('playbooks keys must be nonblank ids');
223
+ }
224
+
225
+ let captain = resolveAgent(top.captain, 'captain', ['from', 'options']);
226
+ if (
227
+ typeof captain.adapter !== 'string' ||
228
+ captain.adapter.trim().length === 0
229
+ ) {
230
+ throw new Error('captain must resolve an adapter');
231
+ }
232
+
233
+ // Validate and detach every config-owned value before provisioning or
234
+ // importing any registry. That keeps malformed config side-effect free.
235
+ const configuredPlaybooks = [];
236
+ const seenHostIds = new Set();
237
+ for (const id of ids) {
238
+ if (id === RESERVED_CAPTAIN_PLAYBOOK_ID) {
239
+ throw new Error(
240
+ `playbooks.${id} collides with the reserved internal Captain id`,
241
+ );
242
+ }
243
+ const block = requireObject(playbooksCfg[id], `playbooks.${id}`);
244
+ 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`);
247
+ }
248
+ if (
249
+ block.command !== undefined &&
250
+ (typeof block.command !== 'string' || block.command.trim().length === 0)
251
+ ) {
252
+ throw new Error(`playbooks.${id}.command must be a nonblank string`);
253
+ }
254
+ if (block.command === RESERVED_CAPTAIN_PLAYBOOK_ID) {
255
+ throw new Error(
256
+ `playbooks.${id}.command collides with the reserved internal Captain command`,
257
+ );
258
+ }
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
+ }
264
+ if (roles.some((role) => role.trim().length === 0)) {
265
+ throw new Error(`playbooks.${id}.players keys must be nonblank role ids`);
266
+ }
267
+ if (roles.includes(RESERVED_CAPTAIN_ROLE_ID)) {
268
+ throw new Error(
269
+ `playbooks.${id}.players.${RESERVED_CAPTAIN_ROLE_ID} binds local ` +
270
+ `role "${RESERVED_CAPTAIN_ROLE_ID}", which is reserved for the ` +
271
+ 'tmux-play Captain',
272
+ );
273
+ }
274
+ const normalizedPlayers = [];
275
+ const generated = [];
276
+ const playerIdEntries = [];
277
+ for (const role of roles) {
278
+ const agent = resolveAgent(
279
+ playersMap[role],
280
+ `playbooks.${id}.players.${role}`,
281
+ ['id'],
282
+ );
283
+ if (
284
+ typeof agent.adapter !== 'string' ||
285
+ agent.adapter.trim().length === 0
286
+ ) {
287
+ throw new Error(
288
+ `playbooks.${id}.players.${role} must resolve an adapter`,
289
+ );
290
+ }
291
+ const hostId = `${id}-${role}`;
292
+ if (seenHostIds.has(hostId)) {
293
+ throw new Error(`generated host player id "${hostId}" is not unique`);
294
+ }
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
+ }
305
+ const optionSlice = Object.fromEntries(
306
+ Object.entries(block).filter(
307
+ ([key]) => !PLAYBOOK_LAUNCHER_KEYS.includes(key),
308
+ ),
309
+ );
310
+ const configuredFrom = canonicalizeRegistrySpecifier(from, configPath);
311
+ configuredPlaybooks.push({
312
+ id,
313
+ from,
314
+ configuredFrom,
315
+ commandOverride: block.command,
316
+ roles,
317
+ normalizedPlayers,
318
+ generated,
319
+ playerIds: Object.fromEntries(playerIdEntries),
320
+ optionSlice,
321
+ });
322
+ }
323
+
324
+ // Both front ends consume the exact installed cligent schema. Normalize a
325
+ // detached provisional projection before any registry preparation/import,
326
+ // then feed its agent and presentation fields back into the authoritative
327
+ // plan. The interactive child will only revalidate these same values.
328
+ const firstVisible = configuredPlaybooks[0].generated;
329
+ const provisional = {
330
+ captain: {
331
+ ...captain,
332
+ from: PLAYBOOK_CAPTAIN_MODULE,
333
+ options: {},
334
+ },
335
+ players: configuredPlaybooks.flatMap((configured) =>
336
+ configured.normalizedPlayers.map(({ id, agent }) => ({
337
+ id,
338
+ ...agent,
339
+ })),
340
+ ),
341
+ layout: {
342
+ ...(isObject(top.layout) ? top.layout : {}),
343
+ initialVisible: firstVisible,
344
+ },
345
+ ...(top.notifications === undefined
346
+ ? {}
347
+ : { notifications: top.notifications }),
348
+ ...(top.theme === undefined ? {} : { theme: top.theme }),
349
+ };
350
+ const normalizedHost = await normalizeHostConfig(provisional);
351
+ const { from: _captainFrom, options: _captainOptions, ...normalizedCaptain } =
352
+ normalizedHost.captain;
353
+ captain = normalizedCaptain;
354
+ const hostAgents = new Map(
355
+ normalizedHost.players.map(({ id, ...agent }) => [id, agent]),
356
+ );
357
+ for (const configured of configuredPlaybooks) {
358
+ configured.normalizedPlayers = configured.normalizedPlayers.map(
359
+ (player) => ({ ...player, agent: hostAgents.get(player.id) }),
360
+ );
361
+ }
362
+
363
+ // Preparation is a transaction-like pre-import phase across the complete
364
+ // configured catalog. A provisioning failure therefore cannot leave some
365
+ // registry modules evaluated and others untouched.
366
+ const preparedPlaybooks = [];
367
+ for (const configured of configuredPlaybooks) {
368
+ const { id, from, configuredFrom } = configured;
369
+ let preparedFrom = configuredFrom;
370
+ if (prepareRegistryModule !== undefined) {
371
+ try {
372
+ const prepared = await prepareRegistryModule({
373
+ id,
374
+ from: configuredFrom,
375
+ authoredFrom: from,
376
+ configPath,
377
+ });
378
+ if (prepared !== undefined) preparedFrom = prepared;
379
+ } catch (cause) {
380
+ throw new Error(
381
+ `playbooks.${id}.from "${from}" failed to prepare: ${errorMessage(cause)}`,
382
+ );
383
+ }
384
+ if (
385
+ typeof preparedFrom !== 'string' ||
386
+ preparedFrom.trim().length === 0
387
+ ) {
388
+ throw new Error(
389
+ `playbooks.${id}.from "${from}" preparation must return a module specifier`,
390
+ );
391
+ }
392
+ }
393
+ preparedPlaybooks.push({ ...configured, preparedFrom });
394
+ }
395
+
396
+ const catalogEntries = [];
397
+ const players = [];
398
+ const seenCommands = new Map();
399
+ const seenIds = new Set();
400
+
401
+ for (const {
402
+ id,
403
+ from,
404
+ preparedFrom,
405
+ commandOverride,
406
+ roles,
407
+ normalizedPlayers,
408
+ playerIds,
409
+ optionSlice,
410
+ } of preparedPlaybooks) {
411
+ let mod;
412
+ try {
413
+ mod = await importModule(preparedFrom);
414
+ } catch (cause) {
415
+ throw new Error(
416
+ `playbooks.${id}.from "${from}" failed to import: ${errorMessage(cause)}`,
417
+ );
418
+ }
419
+ const entry = mod?.default;
420
+ if (!isValidRegistryEntry(entry)) {
421
+ throw new Error(
422
+ `playbooks.${id}.from "${from}" exposes no valid registry entry`,
423
+ );
424
+ }
425
+ if (entry.id !== id) {
426
+ throw new Error(
427
+ `playbooks.${id} key must equal the module manifest id "${entry.id}"`,
428
+ );
429
+ }
430
+ if (seenIds.has(entry.id)) {
431
+ throw new Error(`duplicate playbook id "${entry.id}"`);
432
+ }
433
+ seenIds.add(entry.id);
434
+
435
+ const command = commandOverride ?? entry.command;
436
+ if (command === RESERVED_CAPTAIN_PLAYBOOK_ID) {
437
+ throw new Error(
438
+ `playbooks.${id}.command collides with the reserved internal Captain command`,
439
+ );
440
+ }
441
+ if (seenCommands.has(command)) {
442
+ throw new Error(`duplicate effective command "${command}"`);
443
+ }
444
+ seenCommands.set(command, id);
445
+
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
+ }
459
+
460
+ players.push(...normalizedPlayers);
461
+ catalogEntries.push([
462
+ id,
463
+ {
464
+ id,
465
+ from: preparedFrom,
466
+ // Keep the registry-authored default even when launcher config
467
+ // overrides the effective command. Durable continuation validates
468
+ // this manifest identity but still executes the frozen command.
469
+ manifestCommand: entry.command,
470
+ command,
471
+ ...(commandOverride === undefined ? {} : { commandOverride }),
472
+ intent: entry.intent,
473
+ requiredRoleIds: [...entry.requiredRoleIds],
474
+ playerIds,
475
+ options: optionSlice,
476
+ },
477
+ ]);
478
+ }
479
+
480
+ const presentation = {
481
+ layout: normalizedHost.layout,
482
+ notifications: normalizedHost.notifications,
483
+ ...(normalizedHost.theme === undefined
484
+ ? {}
485
+ : { theme: normalizedHost.theme }),
486
+ };
487
+
488
+ return deepFreeze(
489
+ cloneJson(
490
+ {
491
+ schemaVersion: 1,
492
+ captain,
493
+ players,
494
+ catalog: Object.fromEntries(catalogEntries),
495
+ presentation,
496
+ },
497
+ 'launch config',
498
+ ),
499
+ );
500
+ }
501
+
502
+ // Run cligent's public explicit-config loader over an isolated projection so
503
+ // generic launch planning tracks the installed host schema without importing
504
+ // or duplicating cligent's private validators.
505
+ export async function normalizeHostConfig(config) {
506
+ const dir = mkdtempSync(join(tmpdir(), 'playbook-host-config-'));
507
+ const path = join(dir, 'tmux-play.config.yaml');
508
+ try {
509
+ writeFileSync(path, stringifyYaml(config));
510
+ return (await loadTmuxPlayConfig({ configPath: path })).config;
511
+ } finally {
512
+ rmSync(dir, { recursive: true, force: true });
513
+ }
514
+ }
515
+
516
+ // PBCLI-8/9/10/46: tmux is one projection of the host-neutral plan. The
517
+ // projection is detached so cligent normalization cannot mutate the plan.
518
+ export function projectTmuxConfig(plan) {
519
+ const playbooks = Object.fromEntries(
520
+ Object.entries(plan.catalog).map(([id, item]) => [
521
+ id,
522
+ {
523
+ from: item.from,
524
+ command: item.command,
525
+ options: cloneJson(item.options, `catalog.${id}.options`),
526
+ },
527
+ ]),
528
+ );
529
+ const captain = {
530
+ ...cloneJson(plan.captain, 'captain'),
531
+ from: PLAYBOOK_CAPTAIN_MODULE,
532
+ };
533
+ captain.options = {
534
+ playbooks,
535
+ ...(typeof captain.adapter === 'string' && captain.adapter.length > 0
536
+ ? { captainAdapter: captain.adapter }
537
+ : {}),
538
+ };
539
+ const config = {
540
+ captain,
541
+ players: plan.players.map(({ id, agent }) => ({
542
+ ...cloneJson(agent, `players.${id}.agent`),
543
+ id,
544
+ })),
545
+ layout: projectHostLayout(plan.presentation.layout),
546
+ };
547
+ if (hasOwn(plan.presentation, 'notifications')) {
548
+ config.notifications = cloneJson(
549
+ plan.presentation.notifications,
550
+ 'presentation.notifications',
551
+ );
552
+ }
553
+ if (hasOwn(plan.presentation, 'theme')) {
554
+ config.theme = cloneJson(plan.presentation.theme, 'presentation.theme');
555
+ }
556
+ return config;
557
+ }
558
+
559
+ function projectHostLayout(layout) {
560
+ const projected = cloneJson(layout, 'presentation.layout');
561
+ // cligent's normalized runtime shape carries `columnWeights` as the
562
+ // derived active-shape value alongside both canonical shape fields. The
563
+ // authored schema deliberately rejects that alias/canonical combination,
564
+ // so the interactive child projection must serialize canonical authority
565
+ // only; its loader deterministically derives the same alias again.
566
+ if (
567
+ Array.isArray(projected.singlePlayerColumnWeights) &&
568
+ Array.isArray(projected.multiPlayerColumnWeights)
569
+ ) {
570
+ delete projected.columnWeights;
571
+ }
572
+ return projected;
573
+ }
574
+
575
+ // Compatibility surface used by existing integrations and tests. New hosts
576
+ // consume normalizeLaunchPlan/loadLaunchPlan and choose their projection.
577
+ export async function composeGenericConfig(top, loadModule, configPath) {
578
+ const plan = await normalizeLaunchPlan(top, { loadModule, configPath });
579
+ return {
580
+ config: projectTmuxConfig(plan),
581
+ playbooks: Object.values(plan.catalog).map(({ id, command, intent }) => ({
582
+ id,
583
+ command,
584
+ intent,
585
+ })),
586
+ };
587
+ }
588
+
589
+ export function adaptersFromLaunchPlan(plan) {
590
+ const adapters = new Set();
591
+ if (plan?.captain?.adapter) adapters.add(plan.captain.adapter);
592
+ for (const player of plan?.players ?? []) {
593
+ if (player?.agent?.adapter) adapters.add(player.agent.adapter);
594
+ }
595
+ return [...adapters];
596
+ }
597
+
598
+ export function adaptersFromComposedConfig(config) {
599
+ const adapters = new Set();
600
+ if (config?.captain?.adapter) adapters.add(config.captain.adapter);
601
+ for (const player of config?.players ?? []) {
602
+ if (player?.adapter) adapters.add(player.adapter);
603
+ }
604
+ return [...adapters];
605
+ }
606
+
607
+ export function checkReadiness(adapters, env = process.env, home = homedir()) {
608
+ const failingAdapters = [];
609
+ const unknownAdapters = [];
610
+ for (const adapter of adapters) {
611
+ if (adapter === 'claude') {
612
+ if (!env.ANTHROPIC_API_KEY && !existsSync(join(home, '.claude'))) {
613
+ failingAdapters.push(adapter);
614
+ }
615
+ continue;
616
+ }
617
+ if (adapter === 'codex') {
618
+ if (!env.OPENAI_API_KEY && !existsSync(join(home, '.codex'))) {
619
+ failingAdapters.push(adapter);
620
+ }
621
+ continue;
622
+ }
623
+ unknownAdapters.push(adapter);
624
+ }
625
+ return { failingAdapters, unknownAdapters };
626
+ }
627
+
628
+ export function deriveLaunchReadiness(
629
+ plan,
630
+ env = process.env,
631
+ home = homedir(),
632
+ ) {
633
+ const adapters = adaptersFromLaunchPlan(plan);
634
+ return { adapters, ...checkReadiness(adapters, env, home) };
635
+ }
636
+
637
+ function seedUserConfigIfMissing(userConfigPath, templatePath, onNotice) {
638
+ if (existsSync(userConfigPath)) return;
639
+ mkdirSync(dirname(userConfigPath), { recursive: true });
640
+ copyFileSync(templatePath, userConfigPath, constants.COPYFILE_EXCL);
641
+ onNotice(`playbook: created config at ${userConfigPath}\n`);
642
+ }
643
+
644
+ // DR-021 §3: migrate once, keeping the original before any rewrite.
645
+ function migrateUserConfigIfRetired(userConfigPath, onNotice) {
646
+ let text;
647
+ try {
648
+ text = readFileSync(userConfigPath, 'utf8');
649
+ } catch {
650
+ return;
651
+ }
652
+ let migrated;
653
+ try {
654
+ migrated = migrateRetiredProfiles(text);
655
+ } catch (error) {
656
+ throw new Error(
657
+ `cannot migrate the retired profiles config at ${userConfigPath}: ` +
658
+ `${errorMessage(error)} — edit it by hand: each agent takes its own ` +
659
+ 'adapter, model, effort, and permissions',
660
+ );
661
+ }
662
+ if (migrated === undefined) return;
663
+ const backupPath = freeBackupPath(userConfigPath);
664
+ writeFileSync(backupPath, text, { mode: 0o600 });
665
+ writeFileSync(userConfigPath, migrated);
666
+ onNotice(
667
+ `playbook: migrated ${userConfigPath} to inline agent settings ` +
668
+ `(the top-level "profiles" map was removed in 3.0.0); ` +
669
+ `the original is at ${backupPath}\n`,
670
+ );
671
+ }
672
+
673
+ function freeBackupPath(userConfigPath) {
674
+ const first = `${userConfigPath}.bak`;
675
+ if (!existsSync(first)) return first;
676
+ for (let n = 2; ; n += 1) {
677
+ const candidate = `${userConfigPath}.bak.${n}`;
678
+ if (!existsSync(candidate)) return candidate;
679
+ }
680
+ }
681
+
682
+ // DR-021 §3: rewrite through YAML's Document API so comments survive.
683
+ export function migrateRetiredProfiles(text) {
684
+ const doc = parseYamlDocument(text);
685
+ const contents = doc.contents;
686
+ if (!contents || !Array.isArray(contents.items)) return undefined;
687
+ const profiles = doc.get('profiles');
688
+ const agentPaths = [['captain']];
689
+ const playbooks = doc.get('playbooks');
690
+ if (playbooks && Array.isArray(playbooks.items)) {
691
+ for (const entry of playbooks.items) {
692
+ 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)]);
697
+ }
698
+ }
699
+ }
700
+
701
+ const profileSettings = (name) =>
702
+ profiles && typeof profiles.get === 'function'
703
+ ? profiles.get(name)
704
+ : undefined;
705
+
706
+ let changed = false;
707
+ for (const path of agentPaths) {
708
+ const node = doc.getIn(path, true);
709
+ if (node && typeof node.value === 'string' && !Array.isArray(node.items)) {
710
+ const settings = profileSettings(node.value);
711
+ if (settings === undefined) continue;
712
+ const inlined = settings.clone();
713
+ carryScalarComment(node, inlined);
714
+ doc.setIn(path, inlined);
715
+ changed = true;
716
+ } else if (node && Array.isArray(node.items)) {
717
+ const named = node.get?.('profile');
718
+ if (named === undefined) continue;
719
+ const settings = profileSettings(named);
720
+ if (settings === undefined) {
721
+ throw new Error(
722
+ `${path.join('.')}.profile names "${String(named)}", which no ` +
723
+ 'profiles entry defines',
724
+ );
725
+ }
726
+ node.delete('profile');
727
+ for (const item of settings.items) {
728
+ if (node.has(String(item.key))) continue;
729
+ node.add(item.clone());
730
+ }
731
+ changed = true;
732
+ }
733
+ }
734
+
735
+ if (profiles !== undefined) {
736
+ const index = contents.items.findIndex(
737
+ (item) => String(item.key) === 'profiles',
738
+ );
739
+ const lead =
740
+ index === -1 ? undefined : contents.items[index]?.key?.commentBefore;
741
+ doc.delete('profiles');
742
+ const header = keptHeaderComment(lead);
743
+ const next = contents.items[0];
744
+ if (header !== undefined && next?.key) {
745
+ next.key.commentBefore =
746
+ next.key.commentBefore === undefined
747
+ ? header
748
+ : `${header}\n\n${next.key.commentBefore}`;
749
+ }
750
+ changed = true;
751
+ }
752
+ if (!changed) return undefined;
753
+ doc.commentBefore = MIGRATION_NOTE;
754
+ return doc.toString();
755
+ }
756
+
757
+ const MIGRATION_NOTE =
758
+ ' Migrated by playbook 3.0.0: the top-level `profiles` map was removed and\n' +
759
+ ' each agent now carries its settings inline. The pre-migration file is\n' +
760
+ ' kept beside this one as a .bak. Comments below may still describe the\n' +
761
+ ' retired profiles model.';
762
+
763
+ function carryScalarComment(node, inlined) {
764
+ const parts = [node.commentBefore, node.comment].filter(
765
+ (part) => typeof part === 'string' && part.trim() !== '',
766
+ );
767
+ if (parts.length === 0) return;
768
+ const first = inlined.items?.[0]?.key;
769
+ if (!first) return;
770
+ inlined.flow = false;
771
+ const carried = parts.join('\n');
772
+ first.commentBefore =
773
+ first.commentBefore === undefined
774
+ ? carried
775
+ : `${carried}\n${first.commentBefore}`;
776
+ }
777
+
778
+ function keptHeaderComment(comment) {
779
+ if (typeof comment !== 'string' || comment.trim() === '') return undefined;
780
+ const paragraphs = comment.split('\n\n');
781
+ const kept = paragraphs.slice(0, -1).join('\n\n');
782
+ return kept.trim() === '' ? undefined : kept;
783
+ }
784
+
785
+ function assertNoRetiredProfiles(top, configPath) {
786
+ const where = configPath ? ` in ${configPath}` : '';
787
+ if (top.profiles !== undefined) {
788
+ throw new Error(
789
+ `top-level "profiles" was removed${where}: write each agent's settings ` +
790
+ 'inline under captain and each playbooks.<id>.players.<role> ' +
791
+ '(adapter, model, effort, permissions)',
792
+ );
793
+ }
794
+ 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
+ }
802
+ }
803
+ for (const [path, block] of blocks) {
804
+ if (isObject(block) && block.profile !== undefined) {
805
+ throw new Error(
806
+ `${path}.profile was removed${where}: write the agent's settings ` +
807
+ 'inline in that block (adapter, model, effort, permissions)',
808
+ );
809
+ }
810
+ }
811
+ }
812
+
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'
828
+ );
829
+ }
830
+
831
+ function cloneJson(value, path, seen = new Set()) {
832
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
833
+ return value;
834
+ }
835
+ if (typeof value === 'number') {
836
+ if (Number.isFinite(value)) return value;
837
+ throw new Error(`${path} must contain only finite JSON numbers`);
838
+ }
839
+ if (typeof value !== 'object') {
840
+ throw new Error(`${path} must contain only JSON values`);
841
+ }
842
+ if (seen.has(value)) throw new Error(`${path} must not contain a cycle`);
843
+ seen.add(value);
844
+ try {
845
+ if (Array.isArray(value)) {
846
+ const keys = Reflect.ownKeys(value);
847
+ if (
848
+ keys.some(
849
+ (key) =>
850
+ typeof key === 'symbol' ||
851
+ (key !== 'length' &&
852
+ (!/^(?:0|[1-9]\d*)$/.test(key) || Number(key) >= value.length)),
853
+ )
854
+ ) {
855
+ throw new Error(`${path} must be a plain JSON array`);
856
+ }
857
+ const descriptors = Object.getOwnPropertyDescriptors(value);
858
+ const cloned = [];
859
+ for (let index = 0; index < value.length; index += 1) {
860
+ const descriptor = descriptors[index];
861
+ if (descriptor === undefined) {
862
+ throw new Error(`${path} must not contain sparse array slots`);
863
+ }
864
+ if (
865
+ descriptor.get !== undefined ||
866
+ descriptor.set !== undefined ||
867
+ descriptor.enumerable !== true
868
+ ) {
869
+ throw new Error(
870
+ `${path}[${index}] must be an enumerable data property`,
871
+ );
872
+ }
873
+ cloned.push(
874
+ cloneJson(descriptor.value, `${path}[${index}]`, seen),
875
+ );
876
+ }
877
+ return cloned;
878
+ }
879
+ const prototype = Object.getPrototypeOf(value);
880
+ if (prototype !== Object.prototype && prototype !== null) {
881
+ throw new Error(`${path} must contain only plain JSON objects`);
882
+ }
883
+ const keys = Reflect.ownKeys(value);
884
+ if (keys.some((key) => typeof key === 'symbol')) {
885
+ throw new Error(`${path} must not contain symbol keys`);
886
+ }
887
+ const descriptors = Object.getOwnPropertyDescriptors(value);
888
+ for (const key of keys) {
889
+ const descriptor = descriptors[key];
890
+ if (
891
+ descriptor?.get !== undefined ||
892
+ descriptor?.set !== undefined ||
893
+ descriptor?.enumerable !== true
894
+ ) {
895
+ throw new Error(`${path}.${key} must be an enumerable data property`);
896
+ }
897
+ }
898
+ return Object.fromEntries(
899
+ keys.map((key) => [
900
+ key,
901
+ cloneJson(descriptors[key].value, `${path}.${key}`, seen),
902
+ ]),
903
+ );
904
+ } finally {
905
+ seen.delete(value);
906
+ }
907
+ }
908
+
909
+ function deepFreeze(value) {
910
+ if (value === null || typeof value !== 'object' || Object.isFrozen(value)) {
911
+ return value;
912
+ }
913
+ for (const child of Object.values(value)) deepFreeze(child);
914
+ return Object.freeze(value);
915
+ }
916
+
917
+ function isObject(value) {
918
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
919
+ }
920
+
921
+ function hasOwn(value, key) {
922
+ return Object.prototype.hasOwnProperty.call(value, key);
923
+ }
924
+
925
+ function requireObject(value, path) {
926
+ if (!isObject(value)) throw new Error(`${path} must be an object`);
927
+ return value;
928
+ }
929
+
930
+ function formatKeyList(keys) {
931
+ return `${keys.length === 1 ? 'key' : 'keys'} ${keys
932
+ .map((key) => JSON.stringify(key))
933
+ .join(', ')}`;
934
+ }
935
+
936
+ function errorMessage(error) {
937
+ return error instanceof Error ? error.message : String(error);
938
+ }