@sublang/playbook 0.8.0 → 1.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 (55) hide show
  1. package/README.md +243 -193
  2. package/package.json +52 -17
  3. package/reference/sdlc/captain.md +102 -0
  4. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +227 -0
  5. package/reference/sdlc/captain.playbook/captain.fsm.js +628 -0
  6. package/reference/sdlc/captain.playbook/captain.fsm.ts +851 -0
  7. package/reference/sdlc/captain.playbook/captain.gears.md +60 -0
  8. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +23 -0
  9. package/reference/sdlc/captain.playbook/captain.playbook.js +1053 -0
  10. package/reference/sdlc/captain.playbook/captain.playbook.ts +1144 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +580 -0
  12. package/reference/sdlc/code.playbook/bin/run.js +893 -0
  13. package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -4
  14. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +2 -2
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +1 -1
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +6 -6
  17. package/reference/sdlc/code.playbook/code.fsm.js +334 -102
  18. package/reference/sdlc/code.playbook/code.fsm.ts +470 -182
  19. package/reference/sdlc/code.playbook/code.gears.md +11 -10
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +18 -9
  21. package/reference/sdlc/code.playbook/code.playbook.js +1098 -202
  22. package/reference/sdlc/code.playbook/code.playbook.ts +1440 -258
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +17 -5
  24. package/reference/sdlc/code.playbook/code.registry.js +49 -34
  25. package/reference/sdlc/code.playbook/code.registry.ts +75 -41
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +16 -8
  27. package/reference/sdlc/code.playbook/playbook-captain.js +1005 -240
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1310 -301
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +68 -0
  30. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +396 -0
  31. package/reference/sdlc/discuss.playbook/discuss.fsm.js +2066 -0
  32. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +2464 -0
  33. package/reference/sdlc/discuss.playbook/discuss.gears.md +251 -0
  34. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +113 -0
  35. package/reference/sdlc/discuss.playbook/discuss.playbook.js +1514 -0
  36. package/reference/sdlc/discuss.playbook/discuss.playbook.ts +1926 -0
  37. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +58 -0
  38. package/reference/sdlc/discuss.playbook/discuss.registry.js +97 -0
  39. package/reference/sdlc/discuss.playbook/discuss.registry.ts +153 -0
  40. package/slc/gears2fsm.md +557 -57
  41. package/slc/link.md +1097 -80
  42. package/slc/optimize.md +88 -0
  43. package/slc/text2gears.md +247 -5
  44. package/src/runtime.d.ts +145 -3
  45. package/src/runtime.ts +200 -2
  46. package/src/xstate-runtime.d.ts +94 -0
  47. package/src/xstate-runtime.js +1247 -0
  48. package/src/xstate-runtime.ts +1802 -0
  49. package/reference/sdlc/code.playbook/bin/playbook-code.js +0 -487
  50. package/reference/sdlc/code.playbook/code.tmux-play.d.ts +0 -4
  51. package/reference/sdlc/code.playbook/code.tmux-play.js +0 -11
  52. package/reference/sdlc/code.playbook/code.tmux-play.ts +0 -29
  53. package/reference/sdlc/code.playbook/playbook-code.config.template.yaml +0 -72
  54. package/reference/sdlc/code.playbook/tmux-play.config.yaml +0 -55
  55. package/reference/sdlc/code.playbook/tmux-play.production.config.yaml +0 -38
@@ -0,0 +1,580 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
4
+
5
+ import { spawn } from 'node:child_process';
6
+ import {
7
+ constants,
8
+ copyFileSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ mkdtempSync,
12
+ readFileSync,
13
+ realpathSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
17
+ import { homedir, tmpdir } from 'node:os';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
21
+
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ const templatePath = resolve(here, '..', 'playbook.config.template.yaml');
24
+
25
+ // PBCLI-1/8: the launcher composes a tmux-play config whose Captain is the
26
+ // Playbook Captain shell adapter module.
27
+ export const PLAYBOOK_CAPTAIN_MODULE = '@sublang/playbook/playbook-captain';
28
+ // PBCLI-8/12: known adapter shorthands. A `profiles` id may not collide
29
+ // with one of these, and these are the adapters with readiness predicates.
30
+ const ADAPTER_SHORTHANDS = ['claude', 'codex'];
31
+ // PBCLI-8: launcher-owned keys inside a `playbooks.<id>` block; every other
32
+ // key belongs to that playbook's option slice.
33
+ const PLAYBOOK_LAUNCHER_KEYS = ['from', 'command', 'players'];
34
+ const RESERVED_CAPTAIN_PLAYBOOK_ID = 'captain';
35
+ // PBCLI-9: the bare `captain` id names the tmux-play host Captain, so no
36
+ // playbook-local role may take it.
37
+ const RESERVED_CAPTAIN_ROLE_ID = 'captain';
38
+ const READINESS_FAILURE_EXIT_CODE = 2;
39
+ const COMPOSITION_FAILURE_EXIT_CODE = 1;
40
+
41
+ export async function runPlaybookCli(options = {}) {
42
+ const argv = [...(options.argv ?? process.argv.slice(2))];
43
+ const env = options.env ?? process.env;
44
+ const stdout = options.stdout ?? process.stdout;
45
+ const stderr = options.stderr ?? process.stderr;
46
+ const loadModule = options.loadModule ?? ((specifier) => import(specifier));
47
+
48
+ // PBCLI-18: `playbook run ...` is the non-interactive one-shot path; it
49
+ // never seeds, composes, resolves tmux-play, or launches it.
50
+ if (argv[0] === 'run') {
51
+ const { runPlaybookRun } = await import('./run.js');
52
+ return await runPlaybookRun({
53
+ argv: argv.slice(1),
54
+ stdout,
55
+ stderr,
56
+ ...(options.loadModule ? { loadModule: options.loadModule } : {}),
57
+ ...(options.createAgent ? { createAgent: options.createAgent } : {}),
58
+ ...(options.readStdin ? { readStdin: options.readStdin } : {}),
59
+ ...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
60
+ });
61
+ }
62
+
63
+ const spawnFn = options.spawn ?? spawn;
64
+ const tmuxPlayBin = options.tmuxPlayBin ?? resolveTmuxPlayBin();
65
+ const home = options.homeDir ?? env.HOME ?? homedir();
66
+ const userConfigPath = resolveUserConfigPath(env, home);
67
+
68
+ // PBCLI-6: `--help` / `-h` print help and exit 0 without seeding,
69
+ // composing, or launching.
70
+ if (argv.includes('--help') || argv.includes('-h')) {
71
+ stdout.write(helpText({ userConfigPath }));
72
+ return { code: 0 };
73
+ }
74
+
75
+ // PBCLI-25/26: `--with <path>` overlays are launcher-owned — consumed
76
+ // here, never forwarded to tmux-play, and incompatible with a raw
77
+ // `--config` launch, which bypasses the composition they target.
78
+ let withPaths;
79
+ let forwardArgv;
80
+ try {
81
+ ({ withPaths, rest: forwardArgv } = extractWithFlags(argv));
82
+ } catch (error) {
83
+ stderr.write(`playbook: ${errorMessage(error)}\n`);
84
+ return { code: COMPOSITION_FAILURE_EXIT_CODE };
85
+ }
86
+ if (withPaths.length > 0 && hasExplicitConfig(argv)) {
87
+ stderr.write(
88
+ 'playbook: --with overlays the top-level config and cannot combine ' +
89
+ 'with a raw --config launch\n',
90
+ );
91
+ return { code: COMPOSITION_FAILURE_EXIT_CODE };
92
+ }
93
+
94
+ // PBCLI-1: explicit `--config <path>` launches that raw tmux-play config
95
+ // directly, bypassing seeding, composition, and the readiness gate.
96
+ if (hasExplicitConfig(argv)) {
97
+ return await launchTmuxPlay(spawnFn, [tmuxPlayBin, ...argv], stderr);
98
+ }
99
+
100
+ seedUserConfigIfMissing(userConfigPath, stderr);
101
+
102
+ let composed;
103
+ try {
104
+ let top = parseYaml(readFileSync(userConfigPath, 'utf8')) ?? {};
105
+ if (withPaths.length > 0 && !isObject(top)) {
106
+ throw new Error(
107
+ `the top-level config at ${userConfigPath} must be a YAML map before --with can overlay it`,
108
+ );
109
+ }
110
+ for (const overlayPath of withPaths) {
111
+ top = mergeConfigs(top, loadOverlayFragment(overlayPath));
112
+ }
113
+ composed = await composeGenericConfig(top, loadModule);
114
+ } catch (error) {
115
+ stderr.write(`playbook: ${errorMessage(error)}\n`);
116
+ return { code: COMPOSITION_FAILURE_EXIT_CODE };
117
+ }
118
+
119
+ // PBCLI-5: `--list` prints each configured playbook's id, effective
120
+ // command, and intent without launching tmux-play.
121
+ if (argv.includes('--list')) {
122
+ for (const pb of composed.playbooks) {
123
+ stdout.write(`/${pb.command} ${pb.id} — ${pb.intent}\n`);
124
+ }
125
+ return { code: 0 };
126
+ }
127
+
128
+ // PBCLI-12: readiness reads the adapters of the composed config.
129
+ const readiness = checkReadiness(
130
+ adaptersFromComposedConfig(composed.config),
131
+ env,
132
+ home,
133
+ );
134
+ for (const adapter of readiness.unknownAdapters) {
135
+ stderr.write(
136
+ `playbook: warning: no readiness check for adapter "${adapter}"\n`,
137
+ );
138
+ }
139
+ if (readiness.failingAdapters.length > 0) {
140
+ stderr.write(
141
+ helpText({ userConfigPath, failingAdapters: readiness.failingAdapters }),
142
+ );
143
+ return { code: READINESS_FAILURE_EXIT_CODE };
144
+ }
145
+
146
+ const { dir: tempDir, path: composedPath } = writeComposedConfig(
147
+ composed.config,
148
+ );
149
+ try {
150
+ return await launchTmuxPlay(
151
+ spawnFn,
152
+ [tmuxPlayBin, '--config', composedPath, ...forwardArgv],
153
+ stderr,
154
+ );
155
+ } finally {
156
+ rmSync(tempDir, { recursive: true, force: true });
157
+ }
158
+ }
159
+
160
+ // PBCLI-26: split `--with <path>` pairs out of the argument vector so
161
+ // they are consumed by the launcher rather than forwarded to tmux-play.
162
+ function extractWithFlags(argv) {
163
+ const withPaths = [];
164
+ const rest = [];
165
+ for (let i = 0; i < argv.length; i += 1) {
166
+ const arg = argv[i];
167
+ if (arg === '--with') {
168
+ const value = argv[i + 1];
169
+ if (value === undefined || value === '') {
170
+ throw new Error('--with needs a value');
171
+ }
172
+ withPaths.push(value);
173
+ i += 1;
174
+ } else if (arg.startsWith('--with=')) {
175
+ const value = arg.slice('--with='.length);
176
+ if (!value) throw new Error('--with needs a value');
177
+ withPaths.push(value);
178
+ } else {
179
+ rest.push(arg);
180
+ }
181
+ }
182
+ return { withPaths, rest };
183
+ }
184
+
185
+ // PBCLI-25: an overlay fragment is a top-level-format YAML map.
186
+ function loadOverlayFragment(overlayPath) {
187
+ const resolved = resolve(overlayPath);
188
+ let text;
189
+ try {
190
+ text = readFileSync(resolved, 'utf8');
191
+ } catch (error) {
192
+ throw new Error(
193
+ `cannot read --with overlay ${overlayPath}: ${errorMessage(error)}`,
194
+ );
195
+ }
196
+ let fragment;
197
+ try {
198
+ fragment = parseYaml(text);
199
+ } catch (error) {
200
+ throw new Error(
201
+ `cannot parse --with overlay ${overlayPath}: ${errorMessage(error)}`,
202
+ );
203
+ }
204
+ if (!isObject(fragment)) {
205
+ throw new Error(`--with overlay ${overlayPath} must be a YAML map`);
206
+ }
207
+ return fragment;
208
+ }
209
+
210
+ // PBCLI-25/26: recursive merge for plain maps, replacement for every
211
+ // other value; neither input is mutated. Object.fromEntries defines own
212
+ // data properties, so a hostile fragment key such as __proto__ cannot
213
+ // reach the prototype.
214
+ function mergeConfigs(base, overlay) {
215
+ return Object.fromEntries([
216
+ ...Object.entries(base),
217
+ ...Object.entries(overlay).map(([key, value]) => [
218
+ key,
219
+ isObject(base[key]) && isObject(value)
220
+ ? mergeConfigs(base[key], value)
221
+ : value,
222
+ ]),
223
+ ]);
224
+ }
225
+
226
+ export function resolveConfigHome(env = process.env, home = homedir()) {
227
+ return env.XDG_CONFIG_HOME || join(home, '.config');
228
+ }
229
+
230
+ export function resolveUserConfigPath(env = process.env, home = homedir()) {
231
+ return join(resolveConfigHome(env, home), 'playbook', 'playbook.config.yaml');
232
+ }
233
+
234
+ // PBCLI-8: resolve a scalar `captain` / `players.<role>` value as a profile
235
+ // id or adapter shorthand, or a full agent block whose optional `profile`
236
+ // key names a `profiles` entry whose settings are the base under the block's
237
+ // own explicit fields. The composed block carries no `profile` key.
238
+ export function resolveAgent(value, profiles, path) {
239
+ if (typeof value === 'string') {
240
+ if (hasOwn(profiles, value)) return { ...profiles[value] };
241
+ return { adapter: value };
242
+ }
243
+ if (isObject(value)) {
244
+ const { profile, ...rest } = value;
245
+ let base = {};
246
+ if (profile !== undefined) {
247
+ if (typeof profile !== 'string' || !hasOwn(profiles, profile)) {
248
+ throw new Error(`${path}.profile must name a profiles entry`);
249
+ }
250
+ base = { ...profiles[profile] };
251
+ }
252
+ return { ...base, ...rest };
253
+ }
254
+ throw new Error(
255
+ `${path} must be a profile id, an adapter shorthand, or an agent block`,
256
+ );
257
+ }
258
+
259
+ function isValidRegistryEntry(value) {
260
+ if (!isObject(value)) return false;
261
+ return (
262
+ typeof value.id === 'string' &&
263
+ typeof value.command === 'string' &&
264
+ typeof value.intent === 'string' &&
265
+ Array.isArray(value.requiredRoleIds) &&
266
+ typeof value.validateOptions === 'function' &&
267
+ typeof value.createRuntime === 'function'
268
+ );
269
+ }
270
+
271
+ // PBCLI-8/9/10: normalize the top-level `profiles` / `playbooks` config into
272
+ // a tmux-play config (Captain = the shell adapter; `captain.options.playbooks`
273
+ // the normalized enablement; a launch-time namespaced `<id>-<role>` roster;
274
+ // launcher-owned `layout.initialVisible`).
275
+ export async function composeGenericConfig(top, loadModule) {
276
+ const profiles = isObject(top.profiles) ? top.profiles : {};
277
+ for (const id of Object.keys(profiles)) {
278
+ if (ADAPTER_SHORTHANDS.includes(id)) {
279
+ throw new Error(
280
+ `profiles.${id} collides with the "${id}" adapter shorthand`,
281
+ );
282
+ }
283
+ }
284
+
285
+ const playbooksCfg = requireObject(top.playbooks, 'playbooks');
286
+ const ids = Object.keys(playbooksCfg);
287
+ if (ids.length === 0) {
288
+ throw new Error('playbooks must enable at least one playbook');
289
+ }
290
+
291
+ const captain = {
292
+ from: PLAYBOOK_CAPTAIN_MODULE,
293
+ ...resolveAgent(top.captain, profiles, 'captain'),
294
+ };
295
+ if (captain.adapter === undefined) {
296
+ throw new Error('captain must resolve an adapter');
297
+ }
298
+
299
+ const optionsPlaybooks = {};
300
+ const roster = [];
301
+ const listing = [];
302
+ const seenCommands = new Map();
303
+ const seenIds = new Set();
304
+ let firstVisible;
305
+
306
+ for (const id of ids) {
307
+ if (id === RESERVED_CAPTAIN_PLAYBOOK_ID) {
308
+ throw new Error(
309
+ `playbooks.${id} collides with the reserved internal Captain id`,
310
+ );
311
+ }
312
+ const block = requireObject(playbooksCfg[id], `playbooks.${id}`);
313
+ const from = block.from;
314
+ if (typeof from !== 'string' || from.length === 0) {
315
+ throw new Error(`playbooks.${id}.from must be a module specifier`);
316
+ }
317
+ let mod;
318
+ try {
319
+ mod = await loadModule(from);
320
+ } catch (cause) {
321
+ throw new Error(
322
+ `playbooks.${id}.from "${from}" failed to import: ${errorMessage(cause)}`,
323
+ );
324
+ }
325
+ const entry = mod?.default;
326
+ if (!isValidRegistryEntry(entry)) {
327
+ throw new Error(
328
+ `playbooks.${id}.from "${from}" exposes no valid registry entry`,
329
+ );
330
+ }
331
+ if (entry.id !== id) {
332
+ throw new Error(
333
+ `playbooks.${id} key must equal the module manifest id "${entry.id}"`,
334
+ );
335
+ }
336
+ if (seenIds.has(entry.id)) {
337
+ throw new Error(`duplicate playbook id "${entry.id}"`);
338
+ }
339
+ seenIds.add(entry.id);
340
+
341
+ const command =
342
+ typeof block.command === 'string' && block.command.length > 0
343
+ ? block.command
344
+ : entry.command;
345
+ if (command === RESERVED_CAPTAIN_PLAYBOOK_ID) {
346
+ throw new Error(
347
+ `playbooks.${id}.command collides with the reserved internal Captain command`,
348
+ );
349
+ }
350
+ if (seenCommands.has(command)) {
351
+ throw new Error(`duplicate effective command "${command}"`);
352
+ }
353
+ seenCommands.set(command, id);
354
+
355
+ // PBCLI-9: reject the reserved role before the coverage checks below, so
356
+ // an entry requiring `captain` names the real fault rather than a missing
357
+ // players entry.
358
+ if (entry.requiredRoleIds.includes(RESERVED_CAPTAIN_ROLE_ID)) {
359
+ throw new Error(
360
+ `playbooks.${id} requires local role "${RESERVED_CAPTAIN_ROLE_ID}", ` +
361
+ 'which is reserved for the tmux-play Captain',
362
+ );
363
+ }
364
+
365
+ const playersMap = requireObject(block.players, `playbooks.${id}.players`);
366
+ const roles = Object.keys(playersMap);
367
+ if (roles.includes(RESERVED_CAPTAIN_ROLE_ID)) {
368
+ throw new Error(
369
+ `playbooks.${id}.players.${RESERVED_CAPTAIN_ROLE_ID} binds local ` +
370
+ `role "${RESERVED_CAPTAIN_ROLE_ID}", which is reserved for the ` +
371
+ 'tmux-play Captain',
372
+ );
373
+ }
374
+ if (roles.length === 0) {
375
+ throw new Error(`playbooks.${id} resolves no visible local role`);
376
+ }
377
+ for (const required of entry.requiredRoleIds) {
378
+ if (!roles.includes(required)) {
379
+ throw new Error(
380
+ `playbooks.${id} required role "${required}" has no players entry`,
381
+ );
382
+ }
383
+ }
384
+ const generated = [];
385
+ for (const role of roles) {
386
+ const agent = resolveAgent(
387
+ playersMap[role],
388
+ profiles,
389
+ `playbooks.${id}.players.${role}`,
390
+ );
391
+ if (agent.adapter === undefined) {
392
+ throw new Error(
393
+ `playbooks.${id}.players.${role} must resolve an adapter`,
394
+ );
395
+ }
396
+ const hostId = `${id}-${role}`;
397
+ roster.push({ id: hostId, ...agent });
398
+ generated.push(hostId);
399
+ }
400
+ if (firstVisible === undefined) firstVisible = generated;
401
+
402
+ const optionSlice = {};
403
+ for (const key of Object.keys(block)) {
404
+ if (!PLAYBOOK_LAUNCHER_KEYS.includes(key)) {
405
+ optionSlice[key] = block[key];
406
+ }
407
+ }
408
+ optionsPlaybooks[id] = {
409
+ from,
410
+ ...(typeof block.command === 'string' && block.command.length > 0
411
+ ? { command: block.command }
412
+ : {}),
413
+ options: optionSlice,
414
+ };
415
+ listing.push({ id, command, intent: entry.intent });
416
+ }
417
+
418
+ captain.options = { playbooks: optionsPlaybooks };
419
+ const config = { captain, players: roster };
420
+ // PBCLI-10: carry the user's tmux-play layout window/weight fields through;
421
+ // the launcher owns `layout.initialVisible` (first enabled playbook).
422
+ const layout = isObject(top.layout) ? { ...top.layout } : {};
423
+ layout.initialVisible = firstVisible;
424
+ config.layout = layout;
425
+ if (top.notifications !== undefined) config.notifications = top.notifications;
426
+ if (top.theme !== undefined) config.theme = top.theme;
427
+ return { config, playbooks: listing };
428
+ }
429
+
430
+ export function adaptersFromComposedConfig(config) {
431
+ const adapters = new Set();
432
+ if (config?.captain?.adapter) adapters.add(config.captain.adapter);
433
+ for (const player of config?.players ?? []) {
434
+ if (player?.adapter) adapters.add(player.adapter);
435
+ }
436
+ return [...adapters];
437
+ }
438
+
439
+ export function checkReadiness(adapters, env = process.env, home = homedir()) {
440
+ const failingAdapters = [];
441
+ const unknownAdapters = [];
442
+ for (const adapter of adapters) {
443
+ if (adapter === 'claude') {
444
+ if (!env.ANTHROPIC_API_KEY && !existsSync(join(home, '.claude'))) {
445
+ failingAdapters.push(adapter);
446
+ }
447
+ continue;
448
+ }
449
+ if (adapter === 'codex') {
450
+ if (!env.OPENAI_API_KEY && !existsSync(join(home, '.codex'))) {
451
+ failingAdapters.push(adapter);
452
+ }
453
+ continue;
454
+ }
455
+ unknownAdapters.push(adapter);
456
+ }
457
+ return { failingAdapters, unknownAdapters };
458
+ }
459
+
460
+ function writeComposedConfig(composed) {
461
+ const dir = mkdtempSync(join(tmpdir(), 'playbook-'));
462
+ const path = join(dir, 'tmux-play.config.yaml');
463
+ writeFileSync(path, stringifyYaml(composed));
464
+ return { dir, path };
465
+ }
466
+
467
+ function seedUserConfigIfMissing(userConfigPath, stderr) {
468
+ if (existsSync(userConfigPath)) return;
469
+ mkdirSync(dirname(userConfigPath), { recursive: true });
470
+ copyFileSync(templatePath, userConfigPath, constants.COPYFILE_EXCL);
471
+ stderr.write(`playbook: created config at ${userConfigPath}\n`);
472
+ }
473
+
474
+ function hasExplicitConfig(argv) {
475
+ return argv.some((arg) => arg === '--config' || arg.startsWith('--config='));
476
+ }
477
+
478
+ function helpText({ userConfigPath, failingAdapters = [] }) {
479
+ const failures =
480
+ failingAdapters.length > 0
481
+ ? [`Adapters not ready: ${failingAdapters.join(', ')}`, '']
482
+ : [];
483
+ return [
484
+ ...failures,
485
+ 'Usage:',
486
+ ' playbook [--list] [--with <path>]... [--config <path>] [tmux-play options]',
487
+ ' playbook run <from> [task] [options] # non-interactive one-shot',
488
+ ' playbook run resume <session-id> [reply] # answer a parked run',
489
+ ' playbook --help',
490
+ '',
491
+ `Default config: ${userConfigPath}`,
492
+ '',
493
+ ' --with <path> overlays a top-level config fragment (same format as',
494
+ ' the default config) over the default config for this launch only —',
495
+ ' maps merge recursively, other values replace, later files win. The',
496
+ ' default config file is never modified.',
497
+ '',
498
+ 'Adapter setup:',
499
+ ' claude: run Claude Code once or set ANTHROPIC_API_KEY.',
500
+ ' codex: run Codex CLI once or set OPENAI_API_KEY.',
501
+ '',
502
+ 'Agent swap recipe:',
503
+ ' - reuse agent settings under top-level profiles',
504
+ ' - point each playbooks.<id>.captain / players.<role> at a profile id',
505
+ ' or an adapter shorthand (claude, codex)',
506
+ ' - the launcher injects captain.from and the namespaced <id>-<role>',
507
+ ' host players',
508
+ '',
509
+ ].join('\n');
510
+ }
511
+
512
+ async function launchTmuxPlay(spawnFn, childArgs, stderr) {
513
+ return await new Promise((resolveResult) => {
514
+ let child;
515
+ try {
516
+ child = spawnFn(process.execPath, childArgs, { stdio: 'inherit' });
517
+ } catch (error) {
518
+ stderr.write(
519
+ `playbook: failed to launch tmux-play: ${errorMessage(error)}\n`,
520
+ );
521
+ resolveResult({ code: 127 });
522
+ return;
523
+ }
524
+ let settled = false;
525
+ const settle = (result) => {
526
+ if (settled) return;
527
+ settled = true;
528
+ resolveResult(result);
529
+ };
530
+ child.on('error', (err) => {
531
+ stderr.write(
532
+ `playbook: failed to launch tmux-play: ${errorMessage(err)}\n`,
533
+ );
534
+ settle({ code: 127 });
535
+ });
536
+ child.on('exit', (code, signal) => {
537
+ if (signal) settle({ signal });
538
+ else settle({ code: code ?? 0 });
539
+ });
540
+ });
541
+ }
542
+
543
+ function resolveTmuxPlayBin() {
544
+ const tmuxPlayIndexUrl = import.meta.resolve('@sublang/cligent/tmux-play');
545
+ return join(dirname(fileURLToPath(tmuxPlayIndexUrl)), 'cli.js');
546
+ }
547
+
548
+ function isObject(value) {
549
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
550
+ }
551
+
552
+ function hasOwn(value, key) {
553
+ return Object.prototype.hasOwnProperty.call(value, key);
554
+ }
555
+
556
+ function requireObject(value, path) {
557
+ if (!isObject(value)) {
558
+ throw new Error(`${path} must be an object`);
559
+ }
560
+ return value;
561
+ }
562
+
563
+ function errorMessage(error) {
564
+ return error instanceof Error ? error.message : String(error);
565
+ }
566
+
567
+ function isCliEntry(argv1 = process.argv[1], moduleUrl = import.meta.url) {
568
+ if (!argv1) return false;
569
+ try {
570
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
571
+ } catch {
572
+ return false;
573
+ }
574
+ }
575
+
576
+ if (isCliEntry()) {
577
+ const result = await runPlaybookCli();
578
+ if (result.signal) process.kill(process.pid, result.signal);
579
+ else process.exit(result.code ?? 0);
580
+ }