@sublang/playbook 9.0.0 → 11.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 (71) hide show
  1. package/README.md +1 -1
  2. package/docs/cli.md +109 -21
  3. package/docs/configuration.md +89 -40
  4. package/docs/embedding.md +126 -12
  5. package/package.json +14 -3
  6. package/reference/sdlc/captain.md +14 -10
  7. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +33 -13
  8. package/reference/sdlc/captain.playbook/captain.fsm.js +80 -9
  9. package/reference/sdlc/captain.playbook/captain.fsm.ts +137 -18
  10. package/reference/sdlc/captain.playbook/captain.gears.md +10 -6
  11. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +5 -1
  12. package/reference/sdlc/captain.playbook/captain.playbook.js +140 -10
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +188 -16
  14. package/reference/sdlc/code.md +35 -16
  15. package/reference/sdlc/code.playbook/bin/interactive-session.js +228 -23
  16. package/reference/sdlc/code.playbook/bin/launch-config.js +611 -221
  17. package/reference/sdlc/code.playbook/bin/playbook.js +304 -178
  18. package/reference/sdlc/code.playbook/bin/replay-observer.js +221 -0
  19. package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
  20. package/reference/sdlc/code.playbook/bin/run.js +669 -215
  21. package/reference/sdlc/code.playbook/bin/session-store.js +4546 -502
  22. package/reference/sdlc/code.playbook/code.fsm.d.ts +7 -0
  23. package/reference/sdlc/code.playbook/code.fsm.js +74 -25
  24. package/reference/sdlc/code.playbook/code.fsm.ts +83 -29
  25. package/reference/sdlc/code.playbook/code.gears.md +0 -2
  26. package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
  27. package/reference/sdlc/code.playbook/code.playbook.js +54 -2
  28. package/reference/sdlc/code.playbook/code.playbook.ts +75 -6
  29. package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
  30. package/reference/sdlc/code.playbook/code.registry.js +10 -3
  31. package/reference/sdlc/code.playbook/code.registry.ts +23 -5
  32. package/reference/sdlc/code.playbook/playbook-captain.d.ts +103 -8
  33. package/reference/sdlc/code.playbook/playbook-captain.js +1871 -75
  34. package/reference/sdlc/code.playbook/playbook-captain.ts +2801 -102
  35. package/reference/sdlc/code.playbook/playbook.config.template.yaml +14 -10
  36. package/reference/sdlc/code.playbook/session-store.d.ts +82 -0
  37. package/reference/sdlc/code.playbook/session-store.js +113 -0
  38. package/reference/sdlc/decide.md +24 -16
  39. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +7 -0
  40. package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
  41. package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
  42. package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
  43. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +13 -5
  44. package/reference/sdlc/decide.playbook/decide.playbook.js +1712 -91
  45. package/reference/sdlc/decide.playbook/decide.playbook.ts +2677 -136
  46. package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
  47. package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
  48. package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
  49. package/reference/sdlc/review.md +36 -18
  50. package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
  51. package/reference/sdlc/review.playbook/review.fsm.js +133 -12
  52. package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
  53. package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
  54. package/reference/sdlc/review.playbook/review.playbook.js +65 -2
  55. package/reference/sdlc/review.playbook/review.playbook.ts +83 -6
  56. package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
  57. package/reference/sdlc/review.playbook/review.registry.js +10 -3
  58. package/reference/sdlc/review.playbook/review.registry.ts +23 -5
  59. package/slc/gears2fsm.md +6 -5
  60. package/slc/link.md +544 -41
  61. package/src/accepted-outcome.d.ts +18 -0
  62. package/src/accepted-outcome.js +94 -0
  63. package/src/accepted-outcome.ts +140 -0
  64. package/src/runtime.d.ts +164 -3
  65. package/src/runtime.ts +213 -2
  66. package/src/xstate-playbook-runtime.d.ts +149 -10
  67. package/src/xstate-playbook-runtime.js +2569 -270
  68. package/src/xstate-playbook-runtime.ts +4133 -490
  69. package/src/xstate-runtime.d.ts +59 -1
  70. package/src/xstate-runtime.js +866 -7
  71. package/src/xstate-runtime.ts +1397 -7
@@ -2,49 +2,47 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
4
4
 
5
- import { spawn } from 'node:child_process';
6
- import { randomUUID } from 'node:crypto';
7
- import {
8
- mkdtempSync,
9
- realpathSync,
10
- rmSync,
11
- writeFileSync,
12
- } from 'node:fs';
13
- import { homedir, tmpdir } from 'node:os';
14
- import { dirname, join, resolve } from 'node:path';
15
- import { fileURLToPath } from 'node:url';
16
- import { launchManagedTmuxPlay } from '@sublang/cligent/tmux-play';
17
- import { stringify as stringifyYaml } from 'yaml';
5
+ import { spawn } from "node:child_process";
6
+ import { randomUUID } from "node:crypto";
7
+ import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
8
+ import { homedir, tmpdir } from "node:os";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { launchManagedTmuxPlay } from "@sublang/cligent/tmux-play";
12
+ import { stringify as stringifyYaml } from "yaml";
18
13
  import {
19
14
  adapterSdkFailureLines,
20
15
  checkAdapterSdks,
21
16
  mappedSdksFor,
22
17
  probeAdapterSdk,
23
- } from './adapter-sdk.js';
18
+ } from "./adapter-sdk.js";
24
19
  import {
25
20
  adaptersFromLaunchPlan,
26
21
  extractWithFlags,
27
22
  loadLaunchPlan,
28
23
  loadSelectedLaunchPlanDataOnly,
29
24
  projectTmuxConfig,
25
+ resolveLaunchSessionsDir,
26
+ relocateLegacyUserConfig,
27
+ resolveLegacyUserConfigPath,
30
28
  resolveUserConfigPath,
31
29
  checkReadiness,
32
- } from './launch-config.js';
30
+ } from "./launch-config.js";
33
31
  import {
34
32
  createManagedInteractiveSessionCommand,
35
33
  MANAGED_INTERACTIVE_PAYLOAD_KIND,
36
34
  MANAGED_INTERACTIVE_PAYLOAD_SCHEMA_VERSION,
37
- } from './interactive-session.js';
38
- import { prepareConfiguredRegistries } from './provision.js';
39
- import {
40
- executionConfigFromPlan,
41
- } from './run.js';
35
+ publishManagedInteractiveReadinessWitness,
36
+ } from "./interactive-session.js";
37
+ import { prepareConfiguredRegistries } from "./provision.js";
38
+ import { executionConfigFromPlan } from "./run.js";
42
39
  import {
43
40
  assertCaptainSessionExecutionCompatible,
41
+ assertCaptainSessionsDirectoryUsable,
44
42
  createCaptainSessionStore,
45
43
  SESSION_ID_PATTERN,
46
44
  validateCaptainSessionRecord,
47
- } from './session-store.js';
45
+ } from "./session-store.js";
48
46
 
49
47
  // Preserve the established import surface while the CLI itself delegates to
50
48
  // the host-neutral launch-config module.
@@ -64,9 +62,8 @@ export {
64
62
  normalizeLaunchPlan,
65
63
  projectTmuxConfig,
66
64
  resolveAgent,
67
- resolveConfigHome,
68
65
  resolveUserConfigPath,
69
- } from './launch-config.js';
66
+ } from "./launch-config.js";
70
67
 
71
68
  const READINESS_FAILURE_EXIT_CODE = 2;
72
69
  const COMPOSITION_FAILURE_EXIT_CODE = 1;
@@ -77,22 +74,26 @@ export async function runPlaybookCli(options = {}) {
77
74
  const stdout = options.stdout ?? process.stdout;
78
75
  const stderr = options.stderr ?? process.stderr;
79
76
  const loadModule = options.loadModule ?? ((specifier) => import(specifier));
80
- const home = options.homeDir ?? env.HOME ?? homedir();
77
+ const home =
78
+ options.homeDir ??
79
+ (typeof env.HOME === "string" && env.HOME.trim().length > 0
80
+ ? env.HOME
81
+ : homedir());
81
82
  const userConfigPath =
82
83
  options.userConfigPath ?? resolveUserConfigPath(env, home);
83
84
 
84
85
  // PBCLI-18: `playbook run ...` is the non-interactive presentation of the
85
86
  // same generic-config Captain session. It never resolves or launches the
86
87
  // tmux presenter, but it receives the launch inputs shared with this host.
87
- if (argv[0] === 'run') {
88
- const { runPlaybookRun } = await import('./run.js');
88
+ if (argv[0] === "run") {
89
+ const { runPlaybookRun } = await import("./run.js");
89
90
  return await runPlaybookRun({
90
91
  argv: argv.slice(1),
91
92
  stdout,
92
93
  stderr,
93
94
  env,
94
95
  homeDir: home,
95
- userConfigPath,
96
+ ...(options.userConfigPath === undefined ? {} : { userConfigPath }),
96
97
  ...(options.cwd ? { cwd: options.cwd } : {}),
97
98
  ...(options.loadModule ? { loadModule: options.loadModule } : {}),
98
99
  ...(options.readStdin ? { readStdin: options.readStdin } : {}),
@@ -115,9 +116,12 @@ export async function runPlaybookCli(options = {}) {
115
116
  ...(options.createHostRuntime
116
117
  ? { createHostRuntime: options.createHostRuntime }
117
118
  : {}),
118
- ...(options.sessionStore
119
- ? { sessionStore: options.sessionStore }
119
+ ...(options.createEffectLedgerWriteAhead
120
+ ? {
121
+ createEffectLedgerWriteAhead: options.createEffectLedgerWriteAhead,
122
+ }
120
123
  : {}),
124
+ ...(options.sessionStore ? { sessionStore: options.sessionStore } : {}),
121
125
  ...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
122
126
  ...(options.now ? { now: options.now } : {}),
123
127
  ...(options.createSessionTempId
@@ -126,6 +130,12 @@ export async function runPlaybookCli(options = {}) {
126
130
  ...(options.createAttemptId
127
131
  ? { createAttemptId: options.createAttemptId }
128
132
  : {}),
133
+ ...(options.installRetainedGenerationsForLaunch
134
+ ? {
135
+ installRetainedGenerationsForLaunch:
136
+ options.installRetainedGenerationsForLaunch,
137
+ }
138
+ : {}),
129
139
  ...(options.signal ? { signal: options.signal } : {}),
130
140
  // PBCLI-39: the run path gates on SDK availability too.
131
141
  ...(options.probeAdapterSdk
@@ -145,7 +155,7 @@ export async function runPlaybookCli(options = {}) {
145
155
 
146
156
  // PBCLI-6: `--help` / `-h` print help and exit 0 without seeding,
147
157
  // composing, or launching.
148
- if (argv.includes('--help') || argv.includes('-h')) {
158
+ if (argv.includes("--help") || argv.includes("-h")) {
149
159
  stdout.write(helpText({ userConfigPath }));
150
160
  return { code: 0 };
151
161
  }
@@ -163,17 +173,17 @@ export async function runPlaybookCli(options = {}) {
163
173
  }
164
174
  if (withPaths.length > 0 && hasExplicitConfig(argv)) {
165
175
  stderr.write(
166
- 'playbook: --with overlays the top-level config and cannot combine ' +
167
- 'with a raw --config launch\n',
176
+ "playbook: --with overlays the top-level config and cannot combine " +
177
+ "with a raw --config launch\n",
168
178
  );
169
179
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
170
180
  }
171
- const noProvision = forwardArgv.includes('--no-provision');
172
- forwardArgv = forwardArgv.filter((arg) => arg !== '--no-provision');
181
+ const noProvision = forwardArgv.includes("--no-provision");
182
+ forwardArgv = forwardArgv.filter((arg) => arg !== "--no-provision");
173
183
  if (noProvision && hasExplicitConfig(argv)) {
174
184
  stderr.write(
175
- 'playbook: --no-provision applies to configured registry preparation ' +
176
- 'and cannot combine with a raw --config launch\n',
185
+ "playbook: --no-provision applies to configured registry preparation " +
186
+ "and cannot combine with a raw --config launch\n",
177
187
  );
178
188
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
179
189
  }
@@ -198,10 +208,49 @@ export async function runPlaybookCli(options = {}) {
198
208
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
199
209
  }
200
210
 
211
+ // DR-043: only a validated managed launch may relocate the legacy config.
212
+ // Help and raw --config return above without changing either config path;
213
+ // the delegated `run` front end applies the same boundary independently.
214
+ if (options.userConfigPath === undefined) {
215
+ try {
216
+ relocateLegacyUserConfig(
217
+ userConfigPath,
218
+ resolveLegacyUserConfigPath(env, home),
219
+ (line) => stderr.write(line),
220
+ );
221
+ } catch (error) {
222
+ stderr.write(`playbook: ${errorMessage(error)}\n`);
223
+ return { code: COMPOSITION_FAILURE_EXIT_CODE };
224
+ }
225
+ }
226
+
201
227
  const launchCwd = resolve(
202
228
  options.cwd ?? process.cwd(),
203
- interactiveArgs.cwd ?? '.',
229
+ interactiveArgs.cwd ?? ".",
204
230
  );
231
+ let resolvedSessionsDir;
232
+ if (options.sessionStore === undefined) {
233
+ try {
234
+ resolvedSessionsDir = resolveLaunchSessionsDir({
235
+ userConfigPath,
236
+ overlayPaths: withPaths,
237
+ env,
238
+ homeDir: home,
239
+ ...(options.sessionsDir !== undefined
240
+ ? { sessionsDir: options.sessionsDir }
241
+ : {}),
242
+ preparePrimary: interactiveArgs.sessionId === undefined,
243
+ onNotice: (line) => stderr.write(line),
244
+ });
245
+ // PBCLI-78: listing validates the locator but never consumes the store.
246
+ if (!interactiveArgs.list) {
247
+ await assertCaptainSessionsDirectoryUsable(resolvedSessionsDir);
248
+ }
249
+ } catch (error) {
250
+ stderr.write(`playbook: ${errorMessage(error)}\n`);
251
+ return { code: COMPOSITION_FAILURE_EXIT_CODE };
252
+ }
253
+ }
205
254
  // PBCLI-49: selected planning is deliberately provisional. It narrows the
206
255
  // current config before preparation; the pane child later acquires the
207
256
  // lease and repeats the authoritative read before any host/import work.
@@ -209,15 +258,49 @@ export async function runPlaybookCli(options = {}) {
209
258
  let selectedRecord;
210
259
  if (interactiveArgs.sessionId !== undefined) {
211
260
  try {
212
- store = createInteractiveStore(options, env, home);
261
+ store = createInteractiveStore(options, env, home, resolvedSessionsDir);
213
262
  selectedRecord = validateCaptainSessionRecord(
214
263
  await store.read(interactiveArgs.sessionId),
215
264
  );
216
- if (selectedRecord.state !== 'settled') {
265
+ const needsAbandonmentRecovery =
266
+ (selectedRecord.state === "uncertain" &&
267
+ Object.hasOwn(selectedRecord.uncertain, "abandonment")) ||
268
+ (selectedRecord.state === "settled" &&
269
+ Object.hasOwn(selectedRecord, "settledAbandonment"));
270
+ if (selectedRecord.state !== "settled" && !needsAbandonmentRecovery) {
217
271
  throw new Error(
218
272
  `Captain session ${JSON.stringify(interactiveArgs.sessionId)} has an uncertain turn; recover it with playbook run before reopening interactively`,
219
273
  );
220
274
  }
275
+ if (needsAbandonmentRecovery) {
276
+ const recoveryLease = await store.acquire(interactiveArgs.sessionId);
277
+ let recoveryError;
278
+ try {
279
+ selectedRecord = validateCaptainSessionRecord(
280
+ await recoveryLease.recoverUnresolvedEffectAbandonment(),
281
+ );
282
+ } catch (error) {
283
+ recoveryError = error;
284
+ }
285
+ try {
286
+ await recoveryLease.release();
287
+ } catch (error) {
288
+ if (recoveryError !== undefined) {
289
+ throw aggregateOperationalFailures(
290
+ recoveryError,
291
+ error,
292
+ "Captain session abandonment recovery failed and its lease could not be released",
293
+ );
294
+ }
295
+ throw error;
296
+ }
297
+ if (recoveryError !== undefined) throw recoveryError;
298
+ if (selectedRecord.state !== "settled") {
299
+ throw new Error(
300
+ `Captain session ${JSON.stringify(interactiveArgs.sessionId)} abandonment recovery did not settle its turn`,
301
+ );
302
+ }
303
+ }
221
304
  } catch (error) {
222
305
  stderr.write(`playbook: ${errorMessage(error)}\n`);
223
306
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
@@ -243,7 +326,7 @@ export async function runPlaybookCli(options = {}) {
243
326
  enabled: !noProvision,
244
327
  stderr,
245
328
  hostRoots: options.hostRoots,
246
- commandName: 'playbook',
329
+ commandName: "playbook",
247
330
  }),
248
331
  onNotice: (line) => stderr.write(line),
249
332
  });
@@ -310,7 +393,7 @@ export async function runPlaybookCli(options = {}) {
310
393
  spawnFn,
311
394
  [
312
395
  tmuxPlayBin,
313
- '--config',
396
+ "--config",
314
397
  composedPath,
315
398
  ...interactiveArgs.diagnosticArgv,
316
399
  ],
@@ -322,7 +405,7 @@ export async function runPlaybookCli(options = {}) {
322
405
  }
323
406
 
324
407
  try {
325
- store ??= createInteractiveStore(options, env, home);
408
+ store ??= createInteractiveStore(options, env, home, resolvedSessionsDir);
326
409
  } catch (error) {
327
410
  stderr.write(`playbook: ${errorMessage(error)}\n`);
328
411
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
@@ -341,7 +424,10 @@ export async function runPlaybookCli(options = {}) {
341
424
  );
342
425
  } else {
343
426
  sessionId = (options.createLogicalSessionId ?? randomUUID)();
344
- if (typeof sessionId !== 'string' || !SESSION_ID_PATTERN.test(sessionId)) {
427
+ if (
428
+ typeof sessionId !== "string" ||
429
+ !SESSION_ID_PATTERN.test(sessionId)
430
+ ) {
345
431
  throw new Error(
346
432
  `logical session id generator returned a non-UUID value: ${JSON.stringify(sessionId)}`,
347
433
  );
@@ -358,6 +444,7 @@ export async function runPlaybookCli(options = {}) {
358
444
  projectTmuxConfig(plan),
359
445
  );
360
446
  let prepared;
447
+ let managedChildWorkDir;
361
448
  try {
362
449
  throwIfSignalAborted(options.signal);
363
450
  prepared = await awaitManagedPreparation(
@@ -372,13 +459,19 @@ export async function runPlaybookCli(options = {}) {
372
459
  ...(options.adapterImports
373
460
  ? { adapterImports: options.adapterImports }
374
461
  : {}),
375
- createSessionCommand: (context) =>
376
- createManagedInteractiveSessionCommand(
462
+ createSessionCommand: (context) => {
463
+ if (managedChildWorkDir !== undefined) {
464
+ throw new Error(
465
+ "managed tmux-play requested more than one session command",
466
+ );
467
+ }
468
+ managedChildWorkDir = context.workDir;
469
+ return createManagedInteractiveSessionCommand(
377
470
  context,
378
471
  {
379
472
  schemaVersion: MANAGED_INTERACTIVE_PAYLOAD_SCHEMA_VERSION,
380
473
  kind: MANAGED_INTERACTIVE_PAYLOAD_KIND,
381
- mode: selectedRecord ? 'selected' : 'fresh',
474
+ mode: selectedRecord ? "selected" : "fresh",
382
475
  sessionId,
383
476
  cwd,
384
477
  sessionsDir: store.sessionsDir,
@@ -389,20 +482,43 @@ export async function runPlaybookCli(options = {}) {
389
482
  selfBin:
390
483
  options.managedSessionBin ??
391
484
  fileURLToPath(
392
- new URL('./interactive-session.js', import.meta.url),
485
+ new URL("./interactive-session.js", import.meta.url),
393
486
  ),
394
487
  ...(options.execPath ? { execPath: options.execPath } : {}),
395
488
  },
396
- ),
489
+ );
490
+ },
397
491
  }),
398
492
  options.signal,
399
493
  );
400
494
  if (prepared?.sessionId !== sessionId) {
401
495
  await cancelPreparedAfterFailure(
402
496
  prepared,
403
- new Error('managed tmux-play prepared a mismatched session id'),
497
+ new Error("managed tmux-play prepared a mismatched session id"),
404
498
  );
405
499
  }
500
+ if (managedChildWorkDir === undefined) {
501
+ await cancelPreparedAfterFailure(
502
+ prepared,
503
+ new Error("managed tmux-play did not request a session command"),
504
+ );
505
+ }
506
+ if (prepared?.workDir !== managedChildWorkDir) {
507
+ await cancelPreparedAfterFailure(
508
+ prepared,
509
+ new Error("managed tmux-play prepared a mismatched work directory"),
510
+ );
511
+ }
512
+ if (!selectedRecord) {
513
+ try {
514
+ await (
515
+ options.publishManagedReadinessWitness ??
516
+ publishManagedInteractiveReadinessWitness
517
+ )(managedChildWorkDir, sessionId);
518
+ } catch (error) {
519
+ await cancelPreparedAfterFailure(prepared, error);
520
+ }
521
+ }
406
522
  try {
407
523
  await writeStream(
408
524
  stderr,
@@ -421,7 +537,9 @@ export async function runPlaybookCli(options = {}) {
421
537
  });
422
538
  return { code: 0 };
423
539
  } catch (error) {
424
- stderr.write(`playbook: failed to launch managed session: ${errorMessage(error)}\n`);
540
+ stderr.write(
541
+ `playbook: failed to launch managed session: ${errorMessage(error)}\n`,
542
+ );
425
543
  return { code: COMPOSITION_FAILURE_EXIT_CODE };
426
544
  } finally {
427
545
  rmSync(tempDir, { recursive: true, force: true });
@@ -429,21 +547,23 @@ export async function runPlaybookCli(options = {}) {
429
547
  }
430
548
 
431
549
  export function parseInteractiveArgs(argv) {
432
- if (argv.includes('--theme-diagnostics')) {
433
- if (argv.filter((arg) => arg === '--theme-diagnostics').length > 1) {
434
- throw new Error('--theme-diagnostics was repeated');
550
+ if (argv.includes("--theme-diagnostics")) {
551
+ if (argv.filter((arg) => arg === "--theme-diagnostics").length > 1) {
552
+ throw new Error("--theme-diagnostics was repeated");
435
553
  }
436
- if (argv.some((arg) => arg === '--session' || arg.startsWith('--session='))) {
437
- throw new Error('--session cannot combine with --theme-diagnostics');
554
+ if (
555
+ argv.some((arg) => arg === "--session" || arg.startsWith("--session="))
556
+ ) {
557
+ throw new Error("--session cannot combine with --theme-diagnostics");
438
558
  }
439
- if (argv.includes('--list')) {
440
- throw new Error('--list cannot combine with --theme-diagnostics');
559
+ if (argv.includes("--list")) {
560
+ throw new Error("--list cannot combine with --theme-diagnostics");
441
561
  }
442
562
  const recoveryArg = argv.find(
443
563
  (arg) =>
444
- arg === '--continue' ||
445
- arg === '--retry-uncertain' ||
446
- arg === '--discard-uncertain',
564
+ arg === "--continue" ||
565
+ arg === "--retry-uncertain" ||
566
+ arg === "--discard-uncertain",
447
567
  );
448
568
  if (recoveryArg !== undefined) {
449
569
  throw new Error(
@@ -464,32 +584,34 @@ export function parseInteractiveArgs(argv) {
464
584
  let list = false;
465
585
  for (let index = 0; index < argv.length; index += 1) {
466
586
  const arg = argv[index];
467
- if (arg === '--list') {
468
- if (list) throw new Error('--list was repeated');
587
+ if (arg === "--list") {
588
+ if (list) throw new Error("--list was repeated");
469
589
  list = true;
470
590
  continue;
471
591
  }
472
- if (arg === '--session' || arg.startsWith('--session=')) {
592
+ if (arg === "--session" || arg.startsWith("--session=")) {
473
593
  if (sessionId !== undefined) {
474
- throw new Error('interactive --session selector was repeated or combined');
594
+ throw new Error(
595
+ "interactive --session selector was repeated or combined",
596
+ );
475
597
  }
476
- sessionId = optionValue(argv, index, '--session');
477
- if (arg === '--session') index += 1;
598
+ sessionId = optionValue(argv, index, "--session");
599
+ if (arg === "--session") index += 1;
478
600
  if (!SESSION_ID_PATTERN.test(sessionId)) {
479
- throw new Error('--session requires a canonical lowercase UUID');
601
+ throw new Error("--session requires a canonical lowercase UUID");
480
602
  }
481
603
  continue;
482
604
  }
483
- if (arg === '--cwd' || arg.startsWith('--cwd=')) {
484
- if (cwd !== undefined) throw new Error('interactive --cwd was repeated');
485
- cwd = optionValue(argv, index, '--cwd');
486
- if (arg === '--cwd') index += 1;
605
+ if (arg === "--cwd" || arg.startsWith("--cwd=")) {
606
+ if (cwd !== undefined) throw new Error("interactive --cwd was repeated");
607
+ cwd = optionValue(argv, index, "--cwd");
608
+ if (arg === "--cwd") index += 1;
487
609
  continue;
488
610
  }
489
611
  if (
490
- arg === '--continue' ||
491
- arg === '--retry-uncertain' ||
492
- arg === '--discard-uncertain'
612
+ arg === "--continue" ||
613
+ arg === "--retry-uncertain" ||
614
+ arg === "--discard-uncertain"
493
615
  ) {
494
616
  throw new Error(
495
617
  `${arg} is headless recovery syntax; use playbook run with an explicit session`,
@@ -501,14 +623,16 @@ export function parseInteractiveArgs(argv) {
501
623
  }
502
624
  if (sessionId !== undefined && cwd !== undefined) {
503
625
  throw new Error(
504
- 'interactive --cwd cannot combine with --session; the stored working directory is authoritative',
626
+ "interactive --cwd cannot combine with --session; the stored working directory is authoritative",
505
627
  );
506
628
  }
507
629
  if (sessionId !== undefined && list) {
508
- throw new Error('--session cannot combine with --list');
630
+ throw new Error("--session cannot combine with --list");
509
631
  }
510
632
  if (list && cwd !== undefined) {
511
- throw new Error('--cwd applies to a fresh launch and cannot combine with --list');
633
+ throw new Error(
634
+ "--cwd applies to a fresh launch and cannot combine with --list",
635
+ );
512
636
  }
513
637
  return Object.freeze({
514
638
  sessionId,
@@ -529,7 +653,7 @@ export function parseInteractiveArgs(argv) {
529
653
  export async function runPlaybookCliEntry(options = {}) {
530
654
  const processLike = options.processLike ?? process;
531
655
  const entryArgv = options.argv ?? processLike.argv?.slice(2) ?? [];
532
- if (entryArgv[0] !== 'run' && !isManagedInteractiveInvocation(entryArgv)) {
656
+ if (entryArgv[0] !== "run" && !isManagedInteractiveInvocation(entryArgv)) {
533
657
  return runPlaybookCli(options);
534
658
  }
535
659
  const controller = new AbortController();
@@ -541,7 +665,7 @@ export async function runPlaybookCliEntry(options = {}) {
541
665
  processLike.off(signal, handler);
542
666
  }
543
667
  };
544
- for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
668
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
545
669
  handlers[signal] = () => {
546
670
  if (receivedSignal !== undefined) {
547
671
  removeHandlers();
@@ -575,33 +699,33 @@ export async function runPlaybookCliEntry(options = {}) {
575
699
 
576
700
  function isManagedInteractiveInvocation(argv) {
577
701
  return (
578
- !argv.includes('--help') &&
579
- !argv.includes('-h') &&
580
- !argv.includes('--list') &&
581
- !argv.includes('--theme-diagnostics') &&
702
+ !argv.includes("--help") &&
703
+ !argv.includes("-h") &&
704
+ !argv.includes("--list") &&
705
+ !argv.includes("--theme-diagnostics") &&
582
706
  !hasExplicitConfig(argv)
583
707
  );
584
708
  }
585
709
 
586
710
  function writeComposedConfig(composed) {
587
- const dir = mkdtempSync(join(tmpdir(), 'playbook-'));
588
- const path = join(dir, 'tmux-play.config.yaml');
711
+ const dir = mkdtempSync(join(tmpdir(), "playbook-"));
712
+ const path = join(dir, "tmux-play.config.yaml");
589
713
  writeFileSync(path, stringifyYaml(composed));
590
714
  return { dir, path };
591
715
  }
592
716
 
593
717
  function hasExplicitConfig(argv) {
594
- return argv.some((arg) => arg === '--config' || arg.startsWith('--config='));
718
+ return argv.some((arg) => arg === "--config" || arg.startsWith("--config="));
595
719
  }
596
720
 
597
721
  function assertRawConfigHasNoManagedSelector(argv) {
598
722
  const managed = argv.find(
599
723
  (arg) =>
600
- arg === '--session' ||
601
- arg.startsWith('--session=') ||
602
- arg === '--continue' ||
603
- arg === '--retry-uncertain' ||
604
- arg === '--discard-uncertain',
724
+ arg === "--session" ||
725
+ arg.startsWith("--session=") ||
726
+ arg === "--continue" ||
727
+ arg === "--retry-uncertain" ||
728
+ arg === "--discard-uncertain",
605
729
  );
606
730
  if (managed !== undefined) {
607
731
  throw new Error(
@@ -612,21 +736,20 @@ function assertRawConfigHasNoManagedSelector(argv) {
612
736
 
613
737
  function optionValue(argv, index, name) {
614
738
  const arg = argv[index];
615
- const value =
616
- arg === name ? argv[index + 1] : arg.slice(`${name}=`.length);
617
- if (typeof value !== 'string' || value.length === 0) {
739
+ const value = arg === name ? argv[index + 1] : arg.slice(`${name}=`.length);
740
+ if (typeof value !== "string" || value.length === 0) {
618
741
  throw new Error(`${name} requires a value`);
619
742
  }
620
743
  return value;
621
744
  }
622
745
 
623
- function createInteractiveStore(options, env, home) {
746
+ function createInteractiveStore(options, env, home, sessionsDir) {
624
747
  return (
625
748
  options.sessionStore ??
626
749
  createCaptainSessionStore({
627
750
  env,
628
751
  homeDir: home,
629
- ...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
752
+ sessionsDir,
630
753
  ...(options.now ? { now: options.now } : {}),
631
754
  ...(options.createSessionTempId
632
755
  ? { createTempId: options.createSessionTempId }
@@ -638,12 +761,12 @@ function createInteractiveStore(options, env, home) {
638
761
  async function writeStream(stream, text, signal) {
639
762
  throwIfSignalAborted(signal);
640
763
  const ready = stream.write(text);
641
- if (ready !== false || typeof stream.once !== 'function') return;
764
+ if (ready !== false || typeof stream.once !== "function") return;
642
765
  await new Promise((resolvePromise, rejectPromise) => {
643
766
  const cleanup = () => {
644
- stream.off?.('drain', onDrain);
645
- stream.off?.('error', onError);
646
- signal?.removeEventListener('abort', onAbort);
767
+ stream.off?.("drain", onDrain);
768
+ stream.off?.("error", onError);
769
+ signal?.removeEventListener("abort", onAbort);
647
770
  };
648
771
  const onDrain = () => {
649
772
  cleanup();
@@ -655,11 +778,11 @@ async function writeStream(stream, text, signal) {
655
778
  };
656
779
  const onAbort = () => {
657
780
  cleanup();
658
- rejectPromise(signal.reason ?? new Error('operation aborted'));
781
+ rejectPromise(signal.reason ?? new Error("operation aborted"));
659
782
  };
660
- stream.once('drain', onDrain);
661
- stream.once('error', onError);
662
- signal?.addEventListener('abort', onAbort, { once: true });
783
+ stream.once("drain", onDrain);
784
+ stream.once("error", onError);
785
+ signal?.addEventListener("abort", onAbort, { once: true });
663
786
  if (signal?.aborted) onAbort();
664
787
  });
665
788
  }
@@ -671,22 +794,22 @@ async function awaitManagedPreparation(start, signal) {
671
794
 
672
795
  let onAbort;
673
796
  const aborted = new Promise((resolvePromise) => {
674
- onAbort = () => resolvePromise({ type: 'aborted' });
675
- signal.addEventListener('abort', onAbort, { once: true });
797
+ onAbort = () => resolvePromise({ type: "aborted" });
798
+ signal.addEventListener("abort", onAbort, { once: true });
676
799
  if (signal.aborted) onAbort();
677
800
  });
678
801
  const outcome = await Promise.race([
679
802
  launch.then(
680
- (value) => ({ type: 'prepared', value }),
681
- (error) => ({ type: 'failed', error }),
803
+ (value) => ({ type: "prepared", value }),
804
+ (error) => ({ type: "failed", error }),
682
805
  ),
683
806
  aborted,
684
807
  ]);
685
- signal.removeEventListener('abort', onAbort);
686
- if (outcome.type === 'prepared') return outcome.value;
687
- if (outcome.type === 'failed') throw outcome.error;
808
+ signal.removeEventListener("abort", onAbort);
809
+ if (outcome.type === "prepared") return outcome.value;
810
+ if (outcome.type === "failed") throw outcome.error;
688
811
 
689
- const abortError = signal.reason ?? new Error('operation aborted');
812
+ const abortError = signal.reason ?? new Error("operation aborted");
690
813
  let latePrepared;
691
814
  try {
692
815
  latePrepared = await launch;
@@ -694,7 +817,7 @@ async function awaitManagedPreparation(start, signal) {
694
817
  throw aggregateOperationalFailures(
695
818
  abortError,
696
819
  launchError,
697
- 'managed tmux-play preparation failed while retiring an aborted launch',
820
+ "managed tmux-play preparation failed while retiring an aborted launch",
698
821
  );
699
822
  }
700
823
  await cancelPreparedAfterFailure(latePrepared, abortError);
@@ -704,14 +827,16 @@ async function cancelPreparedIfAborted(prepared, signal) {
704
827
  if (!signal?.aborted) return;
705
828
  await cancelPreparedAfterFailure(
706
829
  prepared,
707
- signal.reason ?? new Error('operation aborted'),
830
+ signal.reason ?? new Error("operation aborted"),
708
831
  );
709
832
  }
710
833
 
711
834
  async function cancelPreparedAfterFailure(prepared, primary) {
712
835
  try {
713
- if (typeof prepared?.cancel !== 'function') {
714
- throw new Error('managed tmux-play preparation has no cancellation boundary');
836
+ if (typeof prepared?.cancel !== "function") {
837
+ throw new Error(
838
+ "managed tmux-play preparation has no cancellation boundary",
839
+ );
715
840
  }
716
841
  await prepared.cancel();
717
842
  } catch (cancelError) {
@@ -733,7 +858,7 @@ function aggregateOperationalFailures(primary, secondary, summary) {
733
858
 
734
859
  function throwIfSignalAborted(signal) {
735
860
  if (signal?.aborted) {
736
- throw signal.reason ?? new Error('operation aborted');
861
+ throw signal.reason ?? new Error("operation aborted");
737
862
  }
738
863
  }
739
864
 
@@ -744,76 +869,77 @@ function helpText({
744
869
  }) {
745
870
  const failures =
746
871
  failingAdapters.length > 0
747
- ? [`Adapters not ready: ${failingAdapters.join(', ')}`, '']
872
+ ? [`Adapters not ready: ${failingAdapters.join(", ")}`, ""]
748
873
  : [];
749
874
  return [
750
875
  // PBCLI-40: the SDK remedy leads, because an unusable adapter cannot be
751
876
  // fixed by the credential advice further down.
752
877
  ...sdkFailureLines,
753
878
  ...failures,
754
- 'Usage:',
755
- ' playbook [--with <path>]... [--no-provision] [--cwd <path>]',
756
- ' playbook --session <id> [--with <path>]... [--no-provision]',
757
- ' playbook --list [--with <path>]... [--no-provision]',
758
- ' playbook --theme-diagnostics [--with <path>]... [--cwd <path>]',
759
- ' playbook --config <path> [tmux-play arguments...]',
760
- ' playbook run [--with <path>]... [--no-provision] [--json]',
761
- ' [--verbose] [--] [input]',
762
- ' playbook run (--continue | --session <id>) [reply]',
763
- ' playbook run --session <id> --retry-uncertain',
764
- ' playbook run --session <id> --discard-uncertain',
765
- ' playbook --help',
766
- '',
879
+ "Usage:",
880
+ " playbook [--with <path>]... [--no-provision] [--cwd <path>]",
881
+ " playbook --session <id> [--with <path>]... [--no-provision]",
882
+ " playbook --list [--with <path>]... [--no-provision]",
883
+ " playbook --theme-diagnostics [--with <path>]... [--cwd <path>]",
884
+ " playbook --config <path> [tmux-play arguments...]",
885
+ " playbook run [--with <path>]... [--no-provision] [--json]",
886
+ " [--verbose] [--] [input]",
887
+ " playbook run (--continue | --session <id>) [reply]",
888
+ " playbook run --session <id> --retry-uncertain",
889
+ " playbook run --session <id> --discard-uncertain",
890
+ " playbook --help",
891
+ "",
767
892
  `Default config: ${userConfigPath}`,
768
- '',
769
- ' Only a fresh managed launch accepts --cwd. It creates a durable logical',
770
- ' Captain session and reports `playbook: session <id>` before attach.',
771
- ' Reopen that same session with `playbook --session <id>` or submit one',
772
- ' headless turn with `playbook run --session <id> [reply]`; selected',
773
- ' sessions always retain their stored working directory.',
774
- ' --with <path> overlays a top-level config fragment (same format as',
775
- ' the default config) for a fresh launch or compatible ordinary reopen —',
776
- ' maps merge recursively, other values replace, later files win, and the',
777
- ' default config file is never modified.',
778
- ' --no-provision keeps configured filesystem registries read-only;',
779
- ' any missing engine links remain a launch error.',
780
- ' `playbook run --verbose` prints Captain telemetry topics to stderr.',
781
- ' `playbook run --help` prints complete continuation and recovery usage.',
782
- ' Raw --config and --theme-diagnostics use cligent\'s stock tmux-play',
783
- ' process boundary and do not create or select a durable Captain session.',
784
- '',
785
- 'Adapter setup:',
786
- ' claude: npm install -g @anthropic-ai/claude-agent-sdk, then run',
787
- ' Claude Code once or set ANTHROPIC_API_KEY.',
788
- ' codex: npm install -g @openai/codex-sdk, then run Codex CLI once',
789
- ' or set OPENAI_API_KEY.',
790
- ' Each SDK is an optional peer dependency, so you install only the',
791
- ' vendors your config actually names.',
792
- '',
793
- 'Agent swap recipe:',
794
- ' - set the top-level captain and each stable players.<id> to an',
795
- ' adapter shorthand (claude, codex) or an inline agent block',
796
- ' - bind every playbooks.<id>.roles.<role> explicitly to a player id;',
797
- ' a scalar names the id, while { player, model?, effort? } may retune',
798
- ' one role; boolean false selects the provider default explicitly',
799
- ' - reusing one id deliberately shares that provider conversation;',
800
- ' distinct ids stay isolated even when their agent blocks are equal',
801
- ' - the launcher injects captain.from and retains referenced player ids',
802
- ' verbatim',
803
- '',
804
- 'Migration warning:',
805
- ' playbooks.<id>.players is removed and is not auto-migrated. Move each',
806
- ' agent to top-level players, choose ids for sharing or isolation, and',
807
- ' bind every local role under playbooks.<id>.roles.',
808
- '',
809
- ].join('\n');
893
+ "",
894
+ " Only a fresh managed launch accepts --cwd. It creates a durable logical",
895
+ " Captain session and reports `playbook: session <id>` before attach.",
896
+ " Reopen that same session with `playbook --session <id>` or submit one",
897
+ " headless turn with `playbook run --session <id> [reply]`; selected",
898
+ " sessions always retain their stored working directory.",
899
+ " --with <path> overlays a top-level config fragment (same format as",
900
+ " the default config) for a fresh launch or compatible ordinary reopen —",
901
+ " maps merge recursively, other values replace, later files win, and the",
902
+ " default config file is never modified.",
903
+ " --no-provision keeps configured filesystem registries read-only;",
904
+ " any missing engine links remain a launch error.",
905
+ " `playbook run --verbose` prints Captain telemetry topics to stderr.",
906
+ " `playbook run --help` prints complete continuation and recovery usage.",
907
+ " Raw --config and --theme-diagnostics use cligent's stock tmux-play",
908
+ " process boundary and do not create or select a durable Captain session.",
909
+ "",
910
+ "Adapter setup:",
911
+ " claude: npm install -g @anthropic-ai/claude-agent-sdk, then run",
912
+ " Claude Code once or set ANTHROPIC_API_KEY.",
913
+ " codex: npm install -g @openai/codex-sdk, then run Codex CLI once",
914
+ " or set OPENAI_API_KEY.",
915
+ " Each SDK is an optional peer dependency, so you install only the",
916
+ " vendors your config actually names.",
917
+ "",
918
+ "Agent swap recipe:",
919
+ " - set the top-level captain and each stable players.<id> to an",
920
+ " adapter shorthand (claude, codex) or an inline agent block",
921
+ " - bind every playbooks.<id>.roles.<role> explicitly to a player id;",
922
+ " a scalar names the id, while { player, model?, effort?, fastMode? }",
923
+ " may retune one role; false selects provider-default model/effort,",
924
+ " while fastMode false is a literal disabled request",
925
+ " - reusing one id deliberately shares that provider conversation;",
926
+ " distinct ids stay isolated even when their agent blocks are equal",
927
+ " - the launcher injects captain.from and retains referenced player ids",
928
+ " verbatim",
929
+ "",
930
+ "Migration warning:",
931
+ " playbooks.<id>.players is removed and is not auto-migrated. Move each",
932
+ " agent to top-level players, choose ids for sharing or isolation, and",
933
+ " bind every local role under playbooks.<id>.roles.",
934
+ "",
935
+ ].join("\n");
810
936
  }
811
937
 
812
938
  async function launchTmuxPlay(spawnFn, childArgs, stderr) {
813
939
  return await new Promise((resolveResult) => {
814
940
  let child;
815
941
  try {
816
- child = spawnFn(process.execPath, childArgs, { stdio: 'inherit' });
942
+ child = spawnFn(process.execPath, childArgs, { stdio: "inherit" });
817
943
  } catch (error) {
818
944
  stderr.write(
819
945
  `playbook: failed to launch tmux-play: ${errorMessage(error)}\n`,
@@ -827,13 +953,13 @@ async function launchTmuxPlay(spawnFn, childArgs, stderr) {
827
953
  settled = true;
828
954
  resolveResult(result);
829
955
  };
830
- child.on('error', (err) => {
956
+ child.on("error", (err) => {
831
957
  stderr.write(
832
958
  `playbook: failed to launch tmux-play: ${errorMessage(err)}\n`,
833
959
  );
834
960
  settle({ code: 127 });
835
961
  });
836
- child.on('exit', (code, signal) => {
962
+ child.on("exit", (code, signal) => {
837
963
  if (signal) settle({ signal });
838
964
  else settle({ code: code ?? 0 });
839
965
  });
@@ -841,8 +967,8 @@ async function launchTmuxPlay(spawnFn, childArgs, stderr) {
841
967
  }
842
968
 
843
969
  function resolveTmuxPlayBin() {
844
- const tmuxPlayIndexUrl = import.meta.resolve('@sublang/cligent/tmux-play');
845
- return join(dirname(fileURLToPath(tmuxPlayIndexUrl)), 'cli.js');
970
+ const tmuxPlayIndexUrl = import.meta.resolve("@sublang/cligent/tmux-play");
971
+ return join(dirname(fileURLToPath(tmuxPlayIndexUrl)), "cli.js");
846
972
  }
847
973
 
848
974
  function errorMessage(error) {