@sublang/playbook 10.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.
- package/README.md +1 -1
- package/docs/cli.md +58 -13
- package/docs/configuration.md +89 -40
- package/docs/embedding.md +88 -0
- package/package.json +9 -2
- package/reference/sdlc/code.md +35 -15
- package/reference/sdlc/code.playbook/bin/interactive-session.js +58 -6
- package/reference/sdlc/code.playbook/bin/launch-config.js +499 -241
- package/reference/sdlc/code.playbook/bin/playbook.js +236 -187
- package/reference/sdlc/code.playbook/bin/replay-observer.js +221 -0
- package/reference/sdlc/code.playbook/bin/run.js +355 -203
- package/reference/sdlc/code.playbook/bin/session-store.js +1512 -136
- package/reference/sdlc/code.playbook/playbook-captain.d.ts +4 -1
- package/reference/sdlc/code.playbook/playbook-captain.js +21 -3
- package/reference/sdlc/code.playbook/playbook-captain.ts +42 -6
- package/reference/sdlc/code.playbook/playbook.config.template.yaml +14 -10
- package/reference/sdlc/code.playbook/session-store.d.ts +82 -0
- package/reference/sdlc/code.playbook/session-store.js +113 -0
- package/reference/sdlc/decide.md +24 -15
- package/reference/sdlc/review.md +36 -18
|
@@ -2,50 +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
|
|
6
|
-
import { randomUUID } from
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from
|
|
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
|
|
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
|
|
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
35
|
publishManagedInteractiveReadinessWitness,
|
|
38
|
-
} from
|
|
39
|
-
import { prepareConfiguredRegistries } from
|
|
40
|
-
import {
|
|
41
|
-
executionConfigFromPlan,
|
|
42
|
-
} from './run.js';
|
|
36
|
+
} from "./interactive-session.js";
|
|
37
|
+
import { prepareConfiguredRegistries } from "./provision.js";
|
|
38
|
+
import { executionConfigFromPlan } from "./run.js";
|
|
43
39
|
import {
|
|
44
40
|
assertCaptainSessionExecutionCompatible,
|
|
41
|
+
assertCaptainSessionsDirectoryUsable,
|
|
45
42
|
createCaptainSessionStore,
|
|
46
43
|
SESSION_ID_PATTERN,
|
|
47
44
|
validateCaptainSessionRecord,
|
|
48
|
-
} from
|
|
45
|
+
} from "./session-store.js";
|
|
49
46
|
|
|
50
47
|
// Preserve the established import surface while the CLI itself delegates to
|
|
51
48
|
// the host-neutral launch-config module.
|
|
@@ -65,9 +62,8 @@ export {
|
|
|
65
62
|
normalizeLaunchPlan,
|
|
66
63
|
projectTmuxConfig,
|
|
67
64
|
resolveAgent,
|
|
68
|
-
resolveConfigHome,
|
|
69
65
|
resolveUserConfigPath,
|
|
70
|
-
} from
|
|
66
|
+
} from "./launch-config.js";
|
|
71
67
|
|
|
72
68
|
const READINESS_FAILURE_EXIT_CODE = 2;
|
|
73
69
|
const COMPOSITION_FAILURE_EXIT_CODE = 1;
|
|
@@ -78,22 +74,26 @@ export async function runPlaybookCli(options = {}) {
|
|
|
78
74
|
const stdout = options.stdout ?? process.stdout;
|
|
79
75
|
const stderr = options.stderr ?? process.stderr;
|
|
80
76
|
const loadModule = options.loadModule ?? ((specifier) => import(specifier));
|
|
81
|
-
const home =
|
|
77
|
+
const home =
|
|
78
|
+
options.homeDir ??
|
|
79
|
+
(typeof env.HOME === "string" && env.HOME.trim().length > 0
|
|
80
|
+
? env.HOME
|
|
81
|
+
: homedir());
|
|
82
82
|
const userConfigPath =
|
|
83
83
|
options.userConfigPath ?? resolveUserConfigPath(env, home);
|
|
84
84
|
|
|
85
85
|
// PBCLI-18: `playbook run ...` is the non-interactive presentation of the
|
|
86
86
|
// same generic-config Captain session. It never resolves or launches the
|
|
87
87
|
// tmux presenter, but it receives the launch inputs shared with this host.
|
|
88
|
-
if (argv[0] ===
|
|
89
|
-
const { runPlaybookRun } = await import(
|
|
88
|
+
if (argv[0] === "run") {
|
|
89
|
+
const { runPlaybookRun } = await import("./run.js");
|
|
90
90
|
return await runPlaybookRun({
|
|
91
91
|
argv: argv.slice(1),
|
|
92
92
|
stdout,
|
|
93
93
|
stderr,
|
|
94
94
|
env,
|
|
95
95
|
homeDir: home,
|
|
96
|
-
userConfigPath,
|
|
96
|
+
...(options.userConfigPath === undefined ? {} : { userConfigPath }),
|
|
97
97
|
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
98
98
|
...(options.loadModule ? { loadModule: options.loadModule } : {}),
|
|
99
99
|
...(options.readStdin ? { readStdin: options.readStdin } : {}),
|
|
@@ -118,13 +118,10 @@ export async function runPlaybookCli(options = {}) {
|
|
|
118
118
|
: {}),
|
|
119
119
|
...(options.createEffectLedgerWriteAhead
|
|
120
120
|
? {
|
|
121
|
-
createEffectLedgerWriteAhead:
|
|
122
|
-
options.createEffectLedgerWriteAhead,
|
|
121
|
+
createEffectLedgerWriteAhead: options.createEffectLedgerWriteAhead,
|
|
123
122
|
}
|
|
124
123
|
: {}),
|
|
125
|
-
...(options.sessionStore
|
|
126
|
-
? { sessionStore: options.sessionStore }
|
|
127
|
-
: {}),
|
|
124
|
+
...(options.sessionStore ? { sessionStore: options.sessionStore } : {}),
|
|
128
125
|
...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
|
|
129
126
|
...(options.now ? { now: options.now } : {}),
|
|
130
127
|
...(options.createSessionTempId
|
|
@@ -158,7 +155,7 @@ export async function runPlaybookCli(options = {}) {
|
|
|
158
155
|
|
|
159
156
|
// PBCLI-6: `--help` / `-h` print help and exit 0 without seeding,
|
|
160
157
|
// composing, or launching.
|
|
161
|
-
if (argv.includes(
|
|
158
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
162
159
|
stdout.write(helpText({ userConfigPath }));
|
|
163
160
|
return { code: 0 };
|
|
164
161
|
}
|
|
@@ -176,17 +173,17 @@ export async function runPlaybookCli(options = {}) {
|
|
|
176
173
|
}
|
|
177
174
|
if (withPaths.length > 0 && hasExplicitConfig(argv)) {
|
|
178
175
|
stderr.write(
|
|
179
|
-
|
|
180
|
-
|
|
176
|
+
"playbook: --with overlays the top-level config and cannot combine " +
|
|
177
|
+
"with a raw --config launch\n",
|
|
181
178
|
);
|
|
182
179
|
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
183
180
|
}
|
|
184
|
-
const noProvision = forwardArgv.includes(
|
|
185
|
-
forwardArgv = forwardArgv.filter((arg) => arg !==
|
|
181
|
+
const noProvision = forwardArgv.includes("--no-provision");
|
|
182
|
+
forwardArgv = forwardArgv.filter((arg) => arg !== "--no-provision");
|
|
186
183
|
if (noProvision && hasExplicitConfig(argv)) {
|
|
187
184
|
stderr.write(
|
|
188
|
-
|
|
189
|
-
|
|
185
|
+
"playbook: --no-provision applies to configured registry preparation " +
|
|
186
|
+
"and cannot combine with a raw --config launch\n",
|
|
190
187
|
);
|
|
191
188
|
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
192
189
|
}
|
|
@@ -211,10 +208,49 @@ export async function runPlaybookCli(options = {}) {
|
|
|
211
208
|
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
212
209
|
}
|
|
213
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
|
+
|
|
214
227
|
const launchCwd = resolve(
|
|
215
228
|
options.cwd ?? process.cwd(),
|
|
216
|
-
interactiveArgs.cwd ??
|
|
229
|
+
interactiveArgs.cwd ?? ".",
|
|
217
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
|
+
}
|
|
218
254
|
// PBCLI-49: selected planning is deliberately provisional. It narrows the
|
|
219
255
|
// current config before preparation; the pane child later acquires the
|
|
220
256
|
// lease and repeats the authoritative read before any host/import work.
|
|
@@ -222,16 +258,16 @@ export async function runPlaybookCli(options = {}) {
|
|
|
222
258
|
let selectedRecord;
|
|
223
259
|
if (interactiveArgs.sessionId !== undefined) {
|
|
224
260
|
try {
|
|
225
|
-
store = createInteractiveStore(options, env, home);
|
|
261
|
+
store = createInteractiveStore(options, env, home, resolvedSessionsDir);
|
|
226
262
|
selectedRecord = validateCaptainSessionRecord(
|
|
227
263
|
await store.read(interactiveArgs.sessionId),
|
|
228
264
|
);
|
|
229
265
|
const needsAbandonmentRecovery =
|
|
230
|
-
(selectedRecord.state ===
|
|
231
|
-
Object.hasOwn(selectedRecord.uncertain,
|
|
232
|
-
(selectedRecord.state ===
|
|
233
|
-
Object.hasOwn(selectedRecord,
|
|
234
|
-
if (selectedRecord.state !==
|
|
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) {
|
|
235
271
|
throw new Error(
|
|
236
272
|
`Captain session ${JSON.stringify(interactiveArgs.sessionId)} has an uncertain turn; recover it with playbook run before reopening interactively`,
|
|
237
273
|
);
|
|
@@ -253,13 +289,13 @@ export async function runPlaybookCli(options = {}) {
|
|
|
253
289
|
throw aggregateOperationalFailures(
|
|
254
290
|
recoveryError,
|
|
255
291
|
error,
|
|
256
|
-
|
|
292
|
+
"Captain session abandonment recovery failed and its lease could not be released",
|
|
257
293
|
);
|
|
258
294
|
}
|
|
259
295
|
throw error;
|
|
260
296
|
}
|
|
261
297
|
if (recoveryError !== undefined) throw recoveryError;
|
|
262
|
-
if (selectedRecord.state !==
|
|
298
|
+
if (selectedRecord.state !== "settled") {
|
|
263
299
|
throw new Error(
|
|
264
300
|
`Captain session ${JSON.stringify(interactiveArgs.sessionId)} abandonment recovery did not settle its turn`,
|
|
265
301
|
);
|
|
@@ -290,7 +326,7 @@ export async function runPlaybookCli(options = {}) {
|
|
|
290
326
|
enabled: !noProvision,
|
|
291
327
|
stderr,
|
|
292
328
|
hostRoots: options.hostRoots,
|
|
293
|
-
commandName:
|
|
329
|
+
commandName: "playbook",
|
|
294
330
|
}),
|
|
295
331
|
onNotice: (line) => stderr.write(line),
|
|
296
332
|
});
|
|
@@ -357,7 +393,7 @@ export async function runPlaybookCli(options = {}) {
|
|
|
357
393
|
spawnFn,
|
|
358
394
|
[
|
|
359
395
|
tmuxPlayBin,
|
|
360
|
-
|
|
396
|
+
"--config",
|
|
361
397
|
composedPath,
|
|
362
398
|
...interactiveArgs.diagnosticArgv,
|
|
363
399
|
],
|
|
@@ -369,7 +405,7 @@ export async function runPlaybookCli(options = {}) {
|
|
|
369
405
|
}
|
|
370
406
|
|
|
371
407
|
try {
|
|
372
|
-
store ??= createInteractiveStore(options, env, home);
|
|
408
|
+
store ??= createInteractiveStore(options, env, home, resolvedSessionsDir);
|
|
373
409
|
} catch (error) {
|
|
374
410
|
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
375
411
|
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
@@ -388,7 +424,10 @@ export async function runPlaybookCli(options = {}) {
|
|
|
388
424
|
);
|
|
389
425
|
} else {
|
|
390
426
|
sessionId = (options.createLogicalSessionId ?? randomUUID)();
|
|
391
|
-
if (
|
|
427
|
+
if (
|
|
428
|
+
typeof sessionId !== "string" ||
|
|
429
|
+
!SESSION_ID_PATTERN.test(sessionId)
|
|
430
|
+
) {
|
|
392
431
|
throw new Error(
|
|
393
432
|
`logical session id generator returned a non-UUID value: ${JSON.stringify(sessionId)}`,
|
|
394
433
|
);
|
|
@@ -423,7 +462,7 @@ export async function runPlaybookCli(options = {}) {
|
|
|
423
462
|
createSessionCommand: (context) => {
|
|
424
463
|
if (managedChildWorkDir !== undefined) {
|
|
425
464
|
throw new Error(
|
|
426
|
-
|
|
465
|
+
"managed tmux-play requested more than one session command",
|
|
427
466
|
);
|
|
428
467
|
}
|
|
429
468
|
managedChildWorkDir = context.workDir;
|
|
@@ -432,7 +471,7 @@ export async function runPlaybookCli(options = {}) {
|
|
|
432
471
|
{
|
|
433
472
|
schemaVersion: MANAGED_INTERACTIVE_PAYLOAD_SCHEMA_VERSION,
|
|
434
473
|
kind: MANAGED_INTERACTIVE_PAYLOAD_KIND,
|
|
435
|
-
mode: selectedRecord ?
|
|
474
|
+
mode: selectedRecord ? "selected" : "fresh",
|
|
436
475
|
sessionId,
|
|
437
476
|
cwd,
|
|
438
477
|
sessionsDir: store.sessionsDir,
|
|
@@ -443,7 +482,7 @@ export async function runPlaybookCli(options = {}) {
|
|
|
443
482
|
selfBin:
|
|
444
483
|
options.managedSessionBin ??
|
|
445
484
|
fileURLToPath(
|
|
446
|
-
new URL(
|
|
485
|
+
new URL("./interactive-session.js", import.meta.url),
|
|
447
486
|
),
|
|
448
487
|
...(options.execPath ? { execPath: options.execPath } : {}),
|
|
449
488
|
},
|
|
@@ -455,19 +494,19 @@ export async function runPlaybookCli(options = {}) {
|
|
|
455
494
|
if (prepared?.sessionId !== sessionId) {
|
|
456
495
|
await cancelPreparedAfterFailure(
|
|
457
496
|
prepared,
|
|
458
|
-
new Error(
|
|
497
|
+
new Error("managed tmux-play prepared a mismatched session id"),
|
|
459
498
|
);
|
|
460
499
|
}
|
|
461
500
|
if (managedChildWorkDir === undefined) {
|
|
462
501
|
await cancelPreparedAfterFailure(
|
|
463
502
|
prepared,
|
|
464
|
-
new Error(
|
|
503
|
+
new Error("managed tmux-play did not request a session command"),
|
|
465
504
|
);
|
|
466
505
|
}
|
|
467
506
|
if (prepared?.workDir !== managedChildWorkDir) {
|
|
468
507
|
await cancelPreparedAfterFailure(
|
|
469
508
|
prepared,
|
|
470
|
-
new Error(
|
|
509
|
+
new Error("managed tmux-play prepared a mismatched work directory"),
|
|
471
510
|
);
|
|
472
511
|
}
|
|
473
512
|
if (!selectedRecord) {
|
|
@@ -498,7 +537,9 @@ export async function runPlaybookCli(options = {}) {
|
|
|
498
537
|
});
|
|
499
538
|
return { code: 0 };
|
|
500
539
|
} catch (error) {
|
|
501
|
-
stderr.write(
|
|
540
|
+
stderr.write(
|
|
541
|
+
`playbook: failed to launch managed session: ${errorMessage(error)}\n`,
|
|
542
|
+
);
|
|
502
543
|
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
503
544
|
} finally {
|
|
504
545
|
rmSync(tempDir, { recursive: true, force: true });
|
|
@@ -506,21 +547,23 @@ export async function runPlaybookCli(options = {}) {
|
|
|
506
547
|
}
|
|
507
548
|
|
|
508
549
|
export function parseInteractiveArgs(argv) {
|
|
509
|
-
if (argv.includes(
|
|
510
|
-
if (argv.filter((arg) => arg ===
|
|
511
|
-
throw new Error(
|
|
550
|
+
if (argv.includes("--theme-diagnostics")) {
|
|
551
|
+
if (argv.filter((arg) => arg === "--theme-diagnostics").length > 1) {
|
|
552
|
+
throw new Error("--theme-diagnostics was repeated");
|
|
512
553
|
}
|
|
513
|
-
if (
|
|
514
|
-
|
|
554
|
+
if (
|
|
555
|
+
argv.some((arg) => arg === "--session" || arg.startsWith("--session="))
|
|
556
|
+
) {
|
|
557
|
+
throw new Error("--session cannot combine with --theme-diagnostics");
|
|
515
558
|
}
|
|
516
|
-
if (argv.includes(
|
|
517
|
-
throw new Error(
|
|
559
|
+
if (argv.includes("--list")) {
|
|
560
|
+
throw new Error("--list cannot combine with --theme-diagnostics");
|
|
518
561
|
}
|
|
519
562
|
const recoveryArg = argv.find(
|
|
520
563
|
(arg) =>
|
|
521
|
-
arg ===
|
|
522
|
-
arg ===
|
|
523
|
-
arg ===
|
|
564
|
+
arg === "--continue" ||
|
|
565
|
+
arg === "--retry-uncertain" ||
|
|
566
|
+
arg === "--discard-uncertain",
|
|
524
567
|
);
|
|
525
568
|
if (recoveryArg !== undefined) {
|
|
526
569
|
throw new Error(
|
|
@@ -541,32 +584,34 @@ export function parseInteractiveArgs(argv) {
|
|
|
541
584
|
let list = false;
|
|
542
585
|
for (let index = 0; index < argv.length; index += 1) {
|
|
543
586
|
const arg = argv[index];
|
|
544
|
-
if (arg ===
|
|
545
|
-
if (list) throw new Error(
|
|
587
|
+
if (arg === "--list") {
|
|
588
|
+
if (list) throw new Error("--list was repeated");
|
|
546
589
|
list = true;
|
|
547
590
|
continue;
|
|
548
591
|
}
|
|
549
|
-
if (arg ===
|
|
592
|
+
if (arg === "--session" || arg.startsWith("--session=")) {
|
|
550
593
|
if (sessionId !== undefined) {
|
|
551
|
-
throw new Error(
|
|
594
|
+
throw new Error(
|
|
595
|
+
"interactive --session selector was repeated or combined",
|
|
596
|
+
);
|
|
552
597
|
}
|
|
553
|
-
sessionId = optionValue(argv, index,
|
|
554
|
-
if (arg ===
|
|
598
|
+
sessionId = optionValue(argv, index, "--session");
|
|
599
|
+
if (arg === "--session") index += 1;
|
|
555
600
|
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
|
556
|
-
throw new Error(
|
|
601
|
+
throw new Error("--session requires a canonical lowercase UUID");
|
|
557
602
|
}
|
|
558
603
|
continue;
|
|
559
604
|
}
|
|
560
|
-
if (arg ===
|
|
561
|
-
if (cwd !== undefined) throw new Error(
|
|
562
|
-
cwd = optionValue(argv, index,
|
|
563
|
-
if (arg ===
|
|
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;
|
|
564
609
|
continue;
|
|
565
610
|
}
|
|
566
611
|
if (
|
|
567
|
-
arg ===
|
|
568
|
-
arg ===
|
|
569
|
-
arg ===
|
|
612
|
+
arg === "--continue" ||
|
|
613
|
+
arg === "--retry-uncertain" ||
|
|
614
|
+
arg === "--discard-uncertain"
|
|
570
615
|
) {
|
|
571
616
|
throw new Error(
|
|
572
617
|
`${arg} is headless recovery syntax; use playbook run with an explicit session`,
|
|
@@ -578,14 +623,16 @@ export function parseInteractiveArgs(argv) {
|
|
|
578
623
|
}
|
|
579
624
|
if (sessionId !== undefined && cwd !== undefined) {
|
|
580
625
|
throw new Error(
|
|
581
|
-
|
|
626
|
+
"interactive --cwd cannot combine with --session; the stored working directory is authoritative",
|
|
582
627
|
);
|
|
583
628
|
}
|
|
584
629
|
if (sessionId !== undefined && list) {
|
|
585
|
-
throw new Error(
|
|
630
|
+
throw new Error("--session cannot combine with --list");
|
|
586
631
|
}
|
|
587
632
|
if (list && cwd !== undefined) {
|
|
588
|
-
throw new Error(
|
|
633
|
+
throw new Error(
|
|
634
|
+
"--cwd applies to a fresh launch and cannot combine with --list",
|
|
635
|
+
);
|
|
589
636
|
}
|
|
590
637
|
return Object.freeze({
|
|
591
638
|
sessionId,
|
|
@@ -606,7 +653,7 @@ export function parseInteractiveArgs(argv) {
|
|
|
606
653
|
export async function runPlaybookCliEntry(options = {}) {
|
|
607
654
|
const processLike = options.processLike ?? process;
|
|
608
655
|
const entryArgv = options.argv ?? processLike.argv?.slice(2) ?? [];
|
|
609
|
-
if (entryArgv[0] !==
|
|
656
|
+
if (entryArgv[0] !== "run" && !isManagedInteractiveInvocation(entryArgv)) {
|
|
610
657
|
return runPlaybookCli(options);
|
|
611
658
|
}
|
|
612
659
|
const controller = new AbortController();
|
|
@@ -618,7 +665,7 @@ export async function runPlaybookCliEntry(options = {}) {
|
|
|
618
665
|
processLike.off(signal, handler);
|
|
619
666
|
}
|
|
620
667
|
};
|
|
621
|
-
for (const signal of [
|
|
668
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
622
669
|
handlers[signal] = () => {
|
|
623
670
|
if (receivedSignal !== undefined) {
|
|
624
671
|
removeHandlers();
|
|
@@ -652,33 +699,33 @@ export async function runPlaybookCliEntry(options = {}) {
|
|
|
652
699
|
|
|
653
700
|
function isManagedInteractiveInvocation(argv) {
|
|
654
701
|
return (
|
|
655
|
-
!argv.includes(
|
|
656
|
-
!argv.includes(
|
|
657
|
-
!argv.includes(
|
|
658
|
-
!argv.includes(
|
|
702
|
+
!argv.includes("--help") &&
|
|
703
|
+
!argv.includes("-h") &&
|
|
704
|
+
!argv.includes("--list") &&
|
|
705
|
+
!argv.includes("--theme-diagnostics") &&
|
|
659
706
|
!hasExplicitConfig(argv)
|
|
660
707
|
);
|
|
661
708
|
}
|
|
662
709
|
|
|
663
710
|
function writeComposedConfig(composed) {
|
|
664
|
-
const dir = mkdtempSync(join(tmpdir(),
|
|
665
|
-
const path = join(dir,
|
|
711
|
+
const dir = mkdtempSync(join(tmpdir(), "playbook-"));
|
|
712
|
+
const path = join(dir, "tmux-play.config.yaml");
|
|
666
713
|
writeFileSync(path, stringifyYaml(composed));
|
|
667
714
|
return { dir, path };
|
|
668
715
|
}
|
|
669
716
|
|
|
670
717
|
function hasExplicitConfig(argv) {
|
|
671
|
-
return argv.some((arg) => arg ===
|
|
718
|
+
return argv.some((arg) => arg === "--config" || arg.startsWith("--config="));
|
|
672
719
|
}
|
|
673
720
|
|
|
674
721
|
function assertRawConfigHasNoManagedSelector(argv) {
|
|
675
722
|
const managed = argv.find(
|
|
676
723
|
(arg) =>
|
|
677
|
-
arg ===
|
|
678
|
-
arg.startsWith(
|
|
679
|
-
arg ===
|
|
680
|
-
arg ===
|
|
681
|
-
arg ===
|
|
724
|
+
arg === "--session" ||
|
|
725
|
+
arg.startsWith("--session=") ||
|
|
726
|
+
arg === "--continue" ||
|
|
727
|
+
arg === "--retry-uncertain" ||
|
|
728
|
+
arg === "--discard-uncertain",
|
|
682
729
|
);
|
|
683
730
|
if (managed !== undefined) {
|
|
684
731
|
throw new Error(
|
|
@@ -689,21 +736,20 @@ function assertRawConfigHasNoManagedSelector(argv) {
|
|
|
689
736
|
|
|
690
737
|
function optionValue(argv, index, name) {
|
|
691
738
|
const arg = argv[index];
|
|
692
|
-
const value =
|
|
693
|
-
|
|
694
|
-
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) {
|
|
695
741
|
throw new Error(`${name} requires a value`);
|
|
696
742
|
}
|
|
697
743
|
return value;
|
|
698
744
|
}
|
|
699
745
|
|
|
700
|
-
function createInteractiveStore(options, env, home) {
|
|
746
|
+
function createInteractiveStore(options, env, home, sessionsDir) {
|
|
701
747
|
return (
|
|
702
748
|
options.sessionStore ??
|
|
703
749
|
createCaptainSessionStore({
|
|
704
750
|
env,
|
|
705
751
|
homeDir: home,
|
|
706
|
-
|
|
752
|
+
sessionsDir,
|
|
707
753
|
...(options.now ? { now: options.now } : {}),
|
|
708
754
|
...(options.createSessionTempId
|
|
709
755
|
? { createTempId: options.createSessionTempId }
|
|
@@ -715,12 +761,12 @@ function createInteractiveStore(options, env, home) {
|
|
|
715
761
|
async function writeStream(stream, text, signal) {
|
|
716
762
|
throwIfSignalAborted(signal);
|
|
717
763
|
const ready = stream.write(text);
|
|
718
|
-
if (ready !== false || typeof stream.once !==
|
|
764
|
+
if (ready !== false || typeof stream.once !== "function") return;
|
|
719
765
|
await new Promise((resolvePromise, rejectPromise) => {
|
|
720
766
|
const cleanup = () => {
|
|
721
|
-
stream.off?.(
|
|
722
|
-
stream.off?.(
|
|
723
|
-
signal?.removeEventListener(
|
|
767
|
+
stream.off?.("drain", onDrain);
|
|
768
|
+
stream.off?.("error", onError);
|
|
769
|
+
signal?.removeEventListener("abort", onAbort);
|
|
724
770
|
};
|
|
725
771
|
const onDrain = () => {
|
|
726
772
|
cleanup();
|
|
@@ -732,11 +778,11 @@ async function writeStream(stream, text, signal) {
|
|
|
732
778
|
};
|
|
733
779
|
const onAbort = () => {
|
|
734
780
|
cleanup();
|
|
735
|
-
rejectPromise(signal.reason ?? new Error(
|
|
781
|
+
rejectPromise(signal.reason ?? new Error("operation aborted"));
|
|
736
782
|
};
|
|
737
|
-
stream.once(
|
|
738
|
-
stream.once(
|
|
739
|
-
signal?.addEventListener(
|
|
783
|
+
stream.once("drain", onDrain);
|
|
784
|
+
stream.once("error", onError);
|
|
785
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
740
786
|
if (signal?.aborted) onAbort();
|
|
741
787
|
});
|
|
742
788
|
}
|
|
@@ -748,22 +794,22 @@ async function awaitManagedPreparation(start, signal) {
|
|
|
748
794
|
|
|
749
795
|
let onAbort;
|
|
750
796
|
const aborted = new Promise((resolvePromise) => {
|
|
751
|
-
onAbort = () => resolvePromise({ type:
|
|
752
|
-
signal.addEventListener(
|
|
797
|
+
onAbort = () => resolvePromise({ type: "aborted" });
|
|
798
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
753
799
|
if (signal.aborted) onAbort();
|
|
754
800
|
});
|
|
755
801
|
const outcome = await Promise.race([
|
|
756
802
|
launch.then(
|
|
757
|
-
(value) => ({ type:
|
|
758
|
-
(error) => ({ type:
|
|
803
|
+
(value) => ({ type: "prepared", value }),
|
|
804
|
+
(error) => ({ type: "failed", error }),
|
|
759
805
|
),
|
|
760
806
|
aborted,
|
|
761
807
|
]);
|
|
762
|
-
signal.removeEventListener(
|
|
763
|
-
if (outcome.type ===
|
|
764
|
-
if (outcome.type ===
|
|
808
|
+
signal.removeEventListener("abort", onAbort);
|
|
809
|
+
if (outcome.type === "prepared") return outcome.value;
|
|
810
|
+
if (outcome.type === "failed") throw outcome.error;
|
|
765
811
|
|
|
766
|
-
const abortError = signal.reason ?? new Error(
|
|
812
|
+
const abortError = signal.reason ?? new Error("operation aborted");
|
|
767
813
|
let latePrepared;
|
|
768
814
|
try {
|
|
769
815
|
latePrepared = await launch;
|
|
@@ -771,7 +817,7 @@ async function awaitManagedPreparation(start, signal) {
|
|
|
771
817
|
throw aggregateOperationalFailures(
|
|
772
818
|
abortError,
|
|
773
819
|
launchError,
|
|
774
|
-
|
|
820
|
+
"managed tmux-play preparation failed while retiring an aborted launch",
|
|
775
821
|
);
|
|
776
822
|
}
|
|
777
823
|
await cancelPreparedAfterFailure(latePrepared, abortError);
|
|
@@ -781,14 +827,16 @@ async function cancelPreparedIfAborted(prepared, signal) {
|
|
|
781
827
|
if (!signal?.aborted) return;
|
|
782
828
|
await cancelPreparedAfterFailure(
|
|
783
829
|
prepared,
|
|
784
|
-
signal.reason ?? new Error(
|
|
830
|
+
signal.reason ?? new Error("operation aborted"),
|
|
785
831
|
);
|
|
786
832
|
}
|
|
787
833
|
|
|
788
834
|
async function cancelPreparedAfterFailure(prepared, primary) {
|
|
789
835
|
try {
|
|
790
|
-
if (typeof prepared?.cancel !==
|
|
791
|
-
throw new Error(
|
|
836
|
+
if (typeof prepared?.cancel !== "function") {
|
|
837
|
+
throw new Error(
|
|
838
|
+
"managed tmux-play preparation has no cancellation boundary",
|
|
839
|
+
);
|
|
792
840
|
}
|
|
793
841
|
await prepared.cancel();
|
|
794
842
|
} catch (cancelError) {
|
|
@@ -810,7 +858,7 @@ function aggregateOperationalFailures(primary, secondary, summary) {
|
|
|
810
858
|
|
|
811
859
|
function throwIfSignalAborted(signal) {
|
|
812
860
|
if (signal?.aborted) {
|
|
813
|
-
throw signal.reason ?? new Error(
|
|
861
|
+
throw signal.reason ?? new Error("operation aborted");
|
|
814
862
|
}
|
|
815
863
|
}
|
|
816
864
|
|
|
@@ -821,76 +869,77 @@ function helpText({
|
|
|
821
869
|
}) {
|
|
822
870
|
const failures =
|
|
823
871
|
failingAdapters.length > 0
|
|
824
|
-
? [`Adapters not ready: ${failingAdapters.join(
|
|
872
|
+
? [`Adapters not ready: ${failingAdapters.join(", ")}`, ""]
|
|
825
873
|
: [];
|
|
826
874
|
return [
|
|
827
875
|
// PBCLI-40: the SDK remedy leads, because an unusable adapter cannot be
|
|
828
876
|
// fixed by the credential advice further down.
|
|
829
877
|
...sdkFailureLines,
|
|
830
878
|
...failures,
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
+
"",
|
|
844
892
|
`Default config: ${userConfigPath}`,
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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");
|
|
887
936
|
}
|
|
888
937
|
|
|
889
938
|
async function launchTmuxPlay(spawnFn, childArgs, stderr) {
|
|
890
939
|
return await new Promise((resolveResult) => {
|
|
891
940
|
let child;
|
|
892
941
|
try {
|
|
893
|
-
child = spawnFn(process.execPath, childArgs, { stdio:
|
|
942
|
+
child = spawnFn(process.execPath, childArgs, { stdio: "inherit" });
|
|
894
943
|
} catch (error) {
|
|
895
944
|
stderr.write(
|
|
896
945
|
`playbook: failed to launch tmux-play: ${errorMessage(error)}\n`,
|
|
@@ -904,13 +953,13 @@ async function launchTmuxPlay(spawnFn, childArgs, stderr) {
|
|
|
904
953
|
settled = true;
|
|
905
954
|
resolveResult(result);
|
|
906
955
|
};
|
|
907
|
-
child.on(
|
|
956
|
+
child.on("error", (err) => {
|
|
908
957
|
stderr.write(
|
|
909
958
|
`playbook: failed to launch tmux-play: ${errorMessage(err)}\n`,
|
|
910
959
|
);
|
|
911
960
|
settle({ code: 127 });
|
|
912
961
|
});
|
|
913
|
-
child.on(
|
|
962
|
+
child.on("exit", (code, signal) => {
|
|
914
963
|
if (signal) settle({ signal });
|
|
915
964
|
else settle({ code: code ?? 0 });
|
|
916
965
|
});
|
|
@@ -918,8 +967,8 @@ async function launchTmuxPlay(spawnFn, childArgs, stderr) {
|
|
|
918
967
|
}
|
|
919
968
|
|
|
920
969
|
function resolveTmuxPlayBin() {
|
|
921
|
-
const tmuxPlayIndexUrl = import.meta.resolve(
|
|
922
|
-
return join(dirname(fileURLToPath(tmuxPlayIndexUrl)),
|
|
970
|
+
const tmuxPlayIndexUrl = import.meta.resolve("@sublang/cligent/tmux-play");
|
|
971
|
+
return join(dirname(fileURLToPath(tmuxPlayIndexUrl)), "cli.js");
|
|
923
972
|
}
|
|
924
973
|
|
|
925
974
|
function errorMessage(error) {
|