@sublang/playbook 6.0.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +28 -11
  2. package/docs/cli.md +158 -68
  3. package/docs/configuration.md +246 -108
  4. package/docs/embedding.md +71 -25
  5. package/package.json +6 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +3 -3
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +3 -3
  8. package/reference/sdlc/code.md +1 -1
  9. package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
  10. package/reference/sdlc/code.playbook/bin/launch-config.js +1900 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +573 -535
  12. package/reference/sdlc/code.playbook/bin/provision.js +84 -38
  13. package/reference/sdlc/code.playbook/bin/run.js +1164 -991
  14. package/reference/sdlc/code.playbook/bin/session-store.js +1961 -0
  15. package/reference/sdlc/code.playbook/code.fsm.d.ts +5 -5
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  18. package/reference/sdlc/code.playbook/code.fsm.js +7 -11
  19. package/reference/sdlc/code.playbook/code.fsm.ts +9 -17
  20. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  21. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  22. package/reference/sdlc/code.playbook/code.playbook.js +12 -13
  23. package/reference/sdlc/code.playbook/code.playbook.ts +22 -15
  24. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  25. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  26. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  27. package/reference/sdlc/code.playbook/playbook-captain.d.ts +101 -9
  28. package/reference/sdlc/code.playbook/playbook-captain.js +1690 -213
  29. package/reference/sdlc/code.playbook/playbook-captain.ts +2492 -253
  30. package/reference/sdlc/code.playbook/playbook.config.template.yaml +44 -62
  31. package/reference/sdlc/decide.md +4 -4
  32. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +9 -9
  33. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  34. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  35. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  36. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +9 -13
  37. package/reference/sdlc/decide.playbook/decide.playbook.js +244 -143
  38. package/reference/sdlc/decide.playbook/decide.playbook.ts +326 -171
  39. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  40. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  41. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  42. package/reference/sdlc/review.md +4 -5
  43. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  44. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  45. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  46. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  47. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  48. package/reference/sdlc/review.playbook/review.playbook.js +16 -21
  49. package/reference/sdlc/review.playbook/review.playbook.ts +26 -26
  50. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  51. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  52. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  53. package/slc/gears2fsm.md +27 -23
  54. package/slc/link.md +140 -97
  55. package/slc/text2gears.md +19 -18
  56. package/src/runtime.d.ts +24 -8
  57. package/src/runtime.ts +29 -13
  58. package/src/xstate-playbook-runtime.d.ts +21 -17
  59. package/src/xstate-playbook-runtime.js +301 -159
  60. package/src/xstate-playbook-runtime.ts +405 -186
  61. package/src/xstate-runtime.d.ts +19 -2
  62. package/src/xstate-runtime.js +403 -62
  63. package/src/xstate-runtime.ts +566 -78
@@ -1,1165 +1,1338 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
 
4
- // PBCLI-18/19/20: `playbook run <from> [task]` runs one playbook once,
5
- // non-interactively and without tmux-play, over a headless PlaybookPorts
6
- // host backed by cligent's Cligent. The playbook need not be enabled in
7
- // config; its registry entry is loaded straight from the `<from>` module.
8
- // PBCLI-22/23 (DR-014): a turn that parks awaiting a Boss reply persists
9
- // the session under ${XDG_STATE_HOME:-$HOME/.local/state}/playbook/sessions,
10
- // prints the pending question to stdout, and `playbook run resume
11
- // <session-id> [reply]` (or `--last`) finishes it in a later invocation.
4
+ // PBCLI-18/20 (DR-031): `playbook run [input]` is the non-interactive
5
+ // presentation of the same configured Captain session that `playbook` hosts
6
+ // in tmux. The core below uses cligent's ordinary tmux-play runtime without a
7
+ // presenter; it does not construct a registry runtime or PlaybookPorts itself.
12
8
 
13
9
  import { randomUUID } from 'node:crypto';
14
- import {
15
- mkdir,
16
- readdir,
17
- readFile,
18
- rename,
19
- rm,
20
- writeFile,
21
- } from 'node:fs/promises';
22
10
  import { homedir } from 'node:os';
23
- import { isAbsolute, join, resolve } from 'node:path';
24
- import { fileURLToPath, pathToFileURL } from 'node:url';
25
- import {
26
- Cligent,
27
- isEffortSupported,
28
- supportedEffortValues,
29
- } from '@sublang/cligent';
30
- import { parse as parseYaml } from 'yaml';
31
- import { hiddenControlEnvelope } from '../../../../src/xstate-runtime.js';
11
+ import { resolve } from 'node:path';
12
+ import { isDeepStrictEqual } from 'node:util';
13
+ import { createTmuxPlayRuntime } from '@sublang/cligent/tmux-play';
14
+ import { createPlaybookCaptainShell } from '../playbook-captain.js';
32
15
  import {
33
16
  adapterSdkFailureLines,
34
17
  checkAdapterSdks,
35
18
  mappedSdksFor,
36
19
  probeAdapterSdk,
37
20
  } from './adapter-sdk.js';
38
- import { provisionEngine } from './provision.js';
39
-
40
- // PBCLI-19: adapter shorthands the run host can construct.
41
- const ADAPTER_LOADERS = {
42
- claude: async () =>
43
- (await import('@sublang/cligent/adapters/claude-code')).ClaudeCodeAdapter,
44
- codex: async () => (await import('@sublang/cligent/adapters/codex')).CodexAdapter,
45
- gemini: async () =>
46
- (await import('@sublang/cligent/adapters/gemini')).GeminiAdapter,
47
- opencode: async () =>
48
- (await import('@sublang/cligent/adapters/opencode')).OpenCodeAdapter,
49
- };
21
+ import {
22
+ checkReadiness,
23
+ loadLaunchPlan,
24
+ projectHostAgent,
25
+ resolveUserConfigPath,
26
+ } from './launch-config.js';
27
+ import { prepareConfiguredRegistries } from './provision.js';
28
+ import {
29
+ assertCaptainSessionExecutionCompatible,
30
+ captainSessionSelectedMembers,
31
+ createCaptainSessionStore,
32
+ projectCaptainSessionStructure,
33
+ SESSION_ID_PATTERN,
34
+ validateCaptainSessionExecutionProjection,
35
+ validateCaptainSessionRecord,
36
+ } from './session-store.js';
50
37
 
51
- const DEFAULT_ADAPTER = 'claude';
52
- // PBCLI-18: exit-code map — terminal 0, arg/import 1, failed/aborted 2,
53
- // suspended/quiescent 3.
54
- const EXIT = { terminal: 0, arg: 1, failed: 2, suspended: 3 };
55
- // PBCLI-23: session-file schema version for the park/resume store.
56
- const SESSION_STORE_VERSION = 1;
57
- // PBCLI-23: a session id names a file inside the store; anything with a
58
- // path separator (or any character a fresh UUID cannot contain) would
59
- // escape the 0700-protected directory and must be rejected before join.
60
- const SESSION_REF_PATTERN = /^[A-Za-z0-9-]+$/;
38
+ const EXIT = { ok: 0, argument: 1, turn: 2 };
39
+ const UUID_PATTERN = SESSION_ID_PATTERN;
40
+ class HeadlessHostSetupError extends Error {
41
+ constructor(cause) {
42
+ super(message(cause));
43
+ this.name = 'HeadlessHostSetupError';
44
+ this.cause = cause;
45
+ }
46
+ }
47
+ const RETIRED_FLAGS = new Set([
48
+ '--player',
49
+ '--captain',
50
+ '--option',
51
+ '--cwd',
52
+ '--last',
53
+ '--config',
54
+ ]);
61
55
 
62
56
  export async function runPlaybookRun(options = {}) {
63
- const argv = options.argv ?? [];
57
+ const argv = [...(options.argv ?? [])];
64
58
  const stdout = options.stdout ?? process.stdout;
65
59
  const stderr = options.stderr ?? process.stderr;
66
- const cwdDefault = options.cwd ?? process.cwd();
67
- const loadModule =
68
- options.loadModule ??
69
- ((specifier) => import(registryImportSpecifier(specifier, cwdDefault)));
70
- const createAgent = options.createAgent ?? defaultCreateAgent;
71
- const readStdin = options.readStdin ?? readAllStdin;
72
- const sessionsDir = options.sessionsDir ?? defaultSessionsDir(process.env);
73
- // PBCLI-28/29: config defaults come from the same user config file the
74
- // interactive launcher resolves; tests inject a hermetic path.
75
- const userConfigPath =
76
- options.userConfigPath ?? (await defaultUserConfigPath());
77
- const ctx = {
78
- stdout,
79
- stderr,
80
- cwdDefault,
81
- loadModule,
82
- createAgent,
83
- readStdin,
84
- sessionsDir,
85
- userConfigPath,
86
- // PBCLI-39: the adapter SDK probe and the runtime classifier, injectable
87
- // like createAgent so tests can drive an unavailable or below-floor
88
- // runtime without uninstalling or downgrading one.
89
- probeAdapterSdk: options.probeAdapterSdk ?? probeAdapterSdk,
90
- classifyRuntime: options.classifyRuntime,
91
- // PBCLI-40: the original invocation, preserved on the ephemeral re-run;
92
- // this module receives argv with the leading `run` already consumed.
93
- rawArgv: ['run', ...argv],
94
- ephemeralNpx: options.ephemeralNpx,
95
- // PBCLI-37: injected host package roots let tests provision against
96
- // synthetic trees, like the injected session store.
97
- hostRoots: options.hostRoots,
98
- };
99
60
 
100
61
  let args;
101
62
  try {
102
63
  args = parseRunArgs(argv);
103
64
  } catch (error) {
104
- stderr.write(`playbook run: ${message(error)}\n`);
105
- return { code: EXIT.arg };
65
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
66
+ return { code: EXIT.argument };
106
67
  }
107
68
  if (args.help) {
108
- stdout.write(runHelpText());
109
- return { code: 0 };
110
- }
111
- if (args.resume) return runResume(args, ctx);
112
- return runFirst(args, ctx);
113
- }
114
-
115
- // PBCLI-18/20: the one-shot first run.
116
- async function runFirst(args, ctx) {
117
- const { stderr, cwdDefault, readStdin } = ctx;
118
- if (!args.from) {
119
- stderr.write('playbook run: missing <from> registry module\n');
120
- return { code: EXIT.arg };
69
+ const env = options.env ?? process.env;
70
+ const home = options.homeDir ?? env.HOME ?? homedir();
71
+ const userConfigPath =
72
+ options.userConfigPath ?? resolveUserConfigPath(env, home);
73
+ await writeStream(stdout, runHelpText(userConfigPath));
74
+ return { code: EXIT.ok };
121
75
  }
122
76
 
123
- // PBCLI-36/37 (DR-024): provision engine links for a filesystem module
124
- // before importing it; a resolvable engine is never touched.
125
- const provisioned = await maybeProvision(args.from, args, ctx);
126
- if (provisioned.code !== undefined) return provisioned;
127
-
128
- const loaded = await loadRegistryEntry(args.from, ctx);
129
- if (loaded.code !== undefined) return loaded;
130
- const { entry } = loaded;
77
+ const env = options.env ?? process.env;
78
+ const home = options.homeDir ?? env.HOME ?? homedir();
79
+ const recovering = args.retryUncertain || args.discardUncertain;
80
+ const continuing = args.continue || args.sessionId !== undefined;
81
+ let input = args.input;
131
82
 
132
- let task = args.task;
133
- if (task === undefined) task = (await readStdin()).trim();
134
- if (!task) {
135
- stderr.write('playbook run: empty task; pass it as an argument or on stdin\n');
136
- return { code: EXIT.arg };
83
+ // PBCLI-18/40: a fresh piped producer is drained before config,
84
+ // preparation, import, or readiness. Continuations first inspect the
85
+ // selected record so an uncertain turn never blocks waiting for input;
86
+ // explicit recovery never reads input at all.
87
+ if (!continuing && !recovering) {
88
+ const resolvedInput = await resolveBossInput(input, options, stderr);
89
+ if (!resolvedInput.ok) return { code: EXIT.argument };
90
+ input = resolvedInput.input;
137
91
  }
138
92
 
139
- // PBCLI-28 (DR-017): config-supplied defaults bind only a first run;
140
- // runResume rebuilds the lineup stored with the session.
141
- let runDefaults;
93
+ let store;
142
94
  try {
143
- runDefaults = await loadRunDefaults(ctx.userConfigPath);
95
+ store =
96
+ options.sessionStore ??
97
+ createCaptainSessionStore({
98
+ env,
99
+ homeDir: home,
100
+ ...(options.sessionsDir ? { sessionsDir: options.sessionsDir } : {}),
101
+ ...(options.now ? { now: options.now } : {}),
102
+ ...(options.createSessionTempId
103
+ ? { createTempId: options.createSessionTempId }
104
+ : {}),
105
+ });
144
106
  } catch (error) {
145
- stderr.write(`playbook run: ${message(error)}\n`);
146
- return { code: EXIT.arg };
147
- }
148
-
149
- // PBCLI-19/28: bind every required role, then the captain — flag over
150
- // config default over built-in claude. An unrequired run.players role is
151
- // ignored (the config is global across playbooks); an unrequired
152
- // --player flag stays an error below.
153
- const roleSpecs = new Map(
154
- entry.requiredRoleIds.map((role) => [
155
- role,
156
- {
157
- ...(runDefaults.players.get(role) ??
158
- runDefaults.player ?? { adapter: DEFAULT_ADAPTER }),
159
- },
160
- ]),
161
- );
162
- for (const [role, spec] of args.players) {
163
- if (!roleSpecs.has(role)) {
164
- stderr.write(`playbook run: --player ${role} is not a required role\n`);
165
- return { code: EXIT.arg };
166
- }
167
- roleSpecs.set(role, spec);
168
- }
169
- const captainSpec =
170
- args.captain ?? runDefaults.captain ?? { adapter: DEFAULT_ADAPTER };
171
- const specError = specsDiagnostic([...roleSpecs.values(), captainSpec]);
172
- if (specError !== undefined) {
173
- stderr.write(`playbook run: ${specError}\n`);
174
- return { code: EXIT.arg };
107
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
108
+ return { code: EXIT.argument };
175
109
  }
176
110
 
177
- // PBCLI-39/40: an optional-peer SDK that is not installed fails here,
178
- // before the runtime exists and before any agent call — never mid-turn.
179
- const sdkError = await adapterSdksDiagnostic(
180
- [...roleSpecs.values(), captainSpec],
181
- ctx,
182
- stdinReplayArgs(args, task),
111
+ let priorRecord;
112
+ let lease;
113
+ let sessionId;
114
+ let config;
115
+ let cwd;
116
+ let restoreSnapshot;
117
+ const loadModule = memoizedModuleLoader(
118
+ options.loadModule ?? ((specifier) => import(specifier)),
183
119
  );
184
- if (sdkError !== undefined) {
185
- stderr.write(sdkError);
186
- return { code: EXIT.arg };
187
- }
120
+ const prepareRegistryModule = registryPreparer(args, options, stderr);
188
121
 
189
- let runtime;
190
- try {
191
- runtime = entry.createRuntime({
192
- captainOptions: args.option,
193
- players: playersFromSpecs(roleSpecs),
194
- });
195
- } catch (error) {
196
- stderr.write(`playbook run: ${message(error)}\n`);
197
- return { code: EXIT.arg };
198
- }
122
+ if (continuing) {
123
+ try {
124
+ if (args.sessionId === undefined) {
125
+ const selected = validateCaptainSessionRecord(
126
+ await awaitWithAbort(
127
+ store.latest({
128
+ onLegacyRecord: ({ sessionId: legacyId, path }) =>
129
+ writeStream(
130
+ stderr,
131
+ `playbook run: skipping legacy Captain session ${JSON.stringify(legacyId)} at ${JSON.stringify(path)} because schema 2 has incompatible player identity; move it outside the sessions directory or remove it to silence this warning\n`,
132
+ ),
133
+ }),
134
+ options.signal,
135
+ ),
136
+ );
137
+ sessionId = selected.sessionId;
138
+ } else {
139
+ sessionId = args.sessionId;
140
+ }
141
+ throwIfAborted(options.signal);
142
+ lease = await store.acquire(sessionId);
143
+ throwIfAborted(options.signal);
144
+ const authoritative = await lease.read();
145
+ throwIfAborted(options.signal);
146
+ if (authoritative === undefined) {
147
+ throw new Error(
148
+ `Captain session ${JSON.stringify(sessionId)} does not exist`,
149
+ );
150
+ }
151
+ priorRecord = validateCaptainSessionRecord(authoritative);
152
+ assertLogicalSessionIdDistinct(priorRecord);
153
+ } catch (error) {
154
+ const releaseError = await releaseLease(lease);
155
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
156
+ if (releaseError !== undefined) {
157
+ await writeStream(
158
+ stderr,
159
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
160
+ );
161
+ return { code: EXIT.turn };
162
+ }
163
+ return { code: EXIT.argument };
164
+ }
199
165
 
200
- // PBCLI-23: the record stores everything a resume needs to rebuild the
201
- // identical host. `cwd` is resolved to an absolute path so a resume from
202
- // another directory rebinds the agents to the same place.
203
- const store = {
204
- schemaVersion: SESSION_STORE_VERSION,
205
- sessionId: randomUUID(),
206
- playbookId: entry.id,
207
- from: registryImportSpecifier(args.from, cwdDefault),
208
- cwd: resolve(cwdDefault, args.cwd ?? '.'),
209
- captain: captainSpec,
210
- players: Object.fromEntries(roleSpecs),
211
- option: args.option,
212
- };
213
- return driveTurn({
214
- ctx,
215
- runtime,
216
- store,
217
- text: task,
218
- json: args.json,
219
- verbose: args.verbose,
220
- restoreFrom: undefined,
221
- });
222
- }
166
+ if (priorRecord.state === 'uncertain') {
167
+ if (args.discardUncertain) {
168
+ try {
169
+ throwIfAborted(options.signal);
170
+ const record = await lease.discard({
171
+ attemptId: priorRecord.uncertain.attemptId,
172
+ });
173
+ throwIfAborted(options.signal);
174
+ const releaseError = await releaseLease(lease);
175
+ lease = undefined;
176
+ if (releaseError !== undefined) throw releaseError;
177
+ await writeStream(
178
+ stderr,
179
+ `playbook run: discarded uncertain turn for Captain session ${JSON.stringify(sessionId)}\n`,
180
+ );
181
+ return { code: EXIT.ok, sessionId, record };
182
+ } catch (error) {
183
+ const releaseError = await releaseLease(lease);
184
+ await writeStream(
185
+ stderr,
186
+ `playbook run: cannot discard uncertain Captain turn: ${message(error)}\n`,
187
+ );
188
+ if (releaseError !== undefined) {
189
+ await writeStream(
190
+ stderr,
191
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
192
+ );
193
+ }
194
+ return { code: EXIT.turn };
195
+ }
196
+ }
197
+ if (!args.retryUncertain) {
198
+ const releaseError = await releaseLease(lease);
199
+ lease = undefined;
200
+ await reportUncertainSession(stderr, sessionId);
201
+ if (releaseError !== undefined) {
202
+ await writeStream(
203
+ stderr,
204
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
205
+ );
206
+ return { code: EXIT.turn };
207
+ }
208
+ return { code: EXIT.argument };
209
+ }
210
+ input = priorRecord.uncertain.input;
211
+ } else if (recovering) {
212
+ const releaseError = await releaseLease(lease);
213
+ lease = undefined;
214
+ await writeStream(
215
+ stderr,
216
+ `playbook run: Captain session ${JSON.stringify(sessionId)} has no uncertain turn to recover\n`,
217
+ );
218
+ if (releaseError !== undefined) {
219
+ await writeStream(
220
+ stderr,
221
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
222
+ );
223
+ return { code: EXIT.turn };
224
+ }
225
+ return { code: EXIT.argument };
226
+ } else {
227
+ const resolvedInput = await resolveBossInput(input, options, stderr);
228
+ if (!resolvedInput.ok) {
229
+ const releaseError = await releaseLease(lease);
230
+ if (releaseError !== undefined) {
231
+ await writeStream(
232
+ stderr,
233
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
234
+ );
235
+ return { code: EXIT.turn };
236
+ }
237
+ return { code: EXIT.argument };
238
+ }
239
+ input = resolvedInput.input;
240
+ }
223
241
 
224
- // PBCLI-22/23: continue a persisted parked session.
225
- async function runResume(args, ctx) {
226
- const { stderr, readStdin, sessionsDir } = ctx;
227
- if (
228
- args.players.size > 0 ||
229
- args.captain !== undefined ||
230
- Object.keys(args.option).length > 0 ||
231
- args.cwd !== undefined
232
- ) {
233
- stderr.write(
234
- 'playbook run: resume uses the bindings stored with the session; ' +
235
- 'drop --player/--captain/--option/--cwd\n',
236
- );
237
- return { code: EXIT.arg };
242
+ cwd = priorRecord.cwd;
243
+ restoreSnapshot = priorRecord.snapshot;
238
244
  }
239
245
 
240
- let sessionFile;
241
- let record;
242
- if (args.last) {
243
- const latest = await latestSessionRecord(sessionsDir);
244
- if (!latest) {
245
- stderr.write(`playbook run: no persisted session under ${sessionsDir}\n`);
246
- return { code: EXIT.arg };
247
- }
248
- ({ file: sessionFile, record } = latest);
249
- } else if (args.sessionRef) {
250
- if (!SESSION_REF_PATTERN.test(args.sessionRef)) {
251
- stderr.write(
252
- `playbook run: "${args.sessionRef}" is not a session id\n`,
253
- );
254
- return { code: EXIT.arg };
246
+ if (!continuing || !args.retryUncertain) {
247
+ const userConfigPath =
248
+ options.userConfigPath ?? resolveUserConfigPath(env, home);
249
+ let plan;
250
+ const configNotices = [];
251
+ try {
252
+ throwIfAborted(options.signal);
253
+ plan = await loadLaunchPlan({
254
+ userConfigPath,
255
+ overlayPaths: args.withPaths,
256
+ loadModule,
257
+ prepareRegistryModule,
258
+ onNotice: (line) => configNotices.push(line),
259
+ ...(continuing
260
+ ? {
261
+ selectedMembers: captainSessionSelectedMembers(
262
+ priorRecord.structuralProjection,
263
+ ),
264
+ }
265
+ : {}),
266
+ });
267
+ throwIfAborted(options.signal);
268
+ } catch (error) {
269
+ for (const line of configNotices) await writeStream(stderr, line);
270
+ const releaseError = await releaseLease(lease);
271
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
272
+ if (releaseError !== undefined) {
273
+ await writeStream(
274
+ stderr,
275
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
276
+ );
277
+ return { code: EXIT.turn };
278
+ }
279
+ return { code: EXIT.argument };
255
280
  }
256
- sessionFile = join(sessionsDir, `${args.sessionRef}.json`);
281
+ for (const line of configNotices) await writeStream(stderr, line);
282
+
257
283
  try {
258
- record = JSON.parse(await readFile(sessionFile, 'utf8'));
284
+ const current = executionConfigFromPlan(plan);
285
+ if (continuing) {
286
+ config = assertCaptainSessionExecutionCompatible(
287
+ priorRecord.structuralProjection,
288
+ current,
289
+ );
290
+ } else {
291
+ sessionId = (options.createLogicalSessionId ?? randomUUID)();
292
+ if (typeof sessionId !== 'string' || !UUID_PATTERN.test(sessionId)) {
293
+ throw new Error(
294
+ `logical session id generator returned a non-UUID value: ${JSON.stringify(sessionId)}`,
295
+ );
296
+ }
297
+ config = current;
298
+ cwd = resolve(options.cwd ?? process.cwd());
299
+ }
259
300
  } catch (error) {
260
- stderr.write(
261
- `playbook run: cannot read session ${args.sessionRef}: ${message(error)}\n`,
262
- );
263
- return { code: EXIT.arg };
301
+ const releaseError = await releaseLease(lease);
302
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
303
+ if (releaseError !== undefined) {
304
+ await writeStream(
305
+ stderr,
306
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
307
+ );
308
+ return { code: EXIT.turn };
309
+ }
310
+ return { code: EXIT.argument };
264
311
  }
265
312
  } else {
266
- stderr.write('playbook run: resume needs a <session-id> or --last\n');
267
- return { code: EXIT.arg };
268
- }
269
- if (!isValidSessionRecord(record)) {
270
- stderr.write(
271
- `playbook run: ${sessionFile} is not a schema-version-${SESSION_STORE_VERSION} playbook run session\n`,
272
- );
273
- return { code: EXIT.arg };
313
+ try {
314
+ throwIfAborted(options.signal);
315
+ config = await validateFrozenExecutionConfig(
316
+ priorRecord.structuralProjection,
317
+ priorRecord.uncertain.attemptedExecutionProjection,
318
+ { loadModule, prepareRegistryModule },
319
+ );
320
+ throwIfAborted(options.signal);
321
+ } catch (error) {
322
+ const releaseError = await releaseLease(lease);
323
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
324
+ if (releaseError !== undefined) {
325
+ await writeStream(
326
+ stderr,
327
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
328
+ );
329
+ return { code: EXIT.turn };
330
+ }
331
+ return { code: EXIT.argument };
332
+ }
274
333
  }
275
334
 
276
- let reply = args.task;
277
- if (reply === undefined) reply = (await readStdin()).trim();
278
- if (!reply) {
279
- stderr.write(
280
- 'playbook run: empty reply; pass it as an argument or on stdin\n',
335
+ const adapters = adaptersFromExecutionConfig(config);
336
+ const readiness = checkReadiness(adapters, env, home);
337
+ let sdkReadiness;
338
+ try {
339
+ sdkReadiness = await awaitWithAbort(
340
+ checkAdapterSdks(
341
+ adapters,
342
+ options.probeAdapterSdk ?? probeAdapterSdk,
343
+ ...(options.classifyRuntime ? [options.classifyRuntime] : []),
344
+ ),
345
+ options.signal,
281
346
  );
282
- return { code: EXIT.arg };
283
- }
284
-
285
- // PBCLI-37: a stored filesystem `from` (a file: URL) is probed and
286
- // provisioned on resume exactly as on a first run.
287
- const provisioned = await maybeProvision(record.from, args, ctx);
288
- if (provisioned.code !== undefined) return provisioned;
289
-
290
- const loaded = await loadRegistryEntry(record.from, ctx);
291
- if (loaded.code !== undefined) return loaded;
292
- const { entry } = loaded;
293
- // PBCLI-23: the module may have changed since the session parked; a
294
- // different playbook id means a different machine, which the stored
295
- // snapshot cannot rehydrate.
296
- if (entry.id !== record.playbookId) {
297
- stderr.write(
298
- `playbook run: ${record.from} now exposes playbook "${entry.id}", ` +
299
- `but the stored session belongs to "${record.playbookId}"\n`,
347
+ } catch (error) {
348
+ const releaseError = await releaseLease(lease);
349
+ await writeStream(
350
+ stderr,
351
+ `playbook run: adapter readiness failed: ${message(error)}\n`,
300
352
  );
301
- return { code: EXIT.arg };
353
+ if (releaseError !== undefined) {
354
+ await writeStream(
355
+ stderr,
356
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
357
+ );
358
+ return { code: EXIT.turn };
359
+ }
360
+ return { code: EXIT.argument };
302
361
  }
303
- const roleSpecs = new Map(Object.entries(record.players));
304
- const missingRole = entry.requiredRoleIds.find(
305
- (role) => !roleSpecs.has(role),
306
- );
307
- if (missingRole !== undefined) {
308
- stderr.write(
309
- `playbook run: stored session lacks required role "${missingRole}"; ` +
310
- `the ${record.from} module changed since the session parked\n`,
362
+ for (const adapter of readiness.unknownAdapters) {
363
+ await writeStream(
364
+ stderr,
365
+ `playbook run: warning: no readiness check for adapter "${adapter}"\n`,
311
366
  );
312
- return { code: EXIT.arg };
313
367
  }
314
- const specError = specsDiagnostic([...roleSpecs.values(), record.captain]);
315
- if (specError !== undefined) {
316
- stderr.write(`playbook run: ${specError}\n`);
317
- return { code: EXIT.arg };
368
+ if (
369
+ readiness.failingAdapters.length > 0 ||
370
+ sdkReadiness.unusableAdapters.length > 0
371
+ ) {
372
+ await reportReadinessFailure({
373
+ stderr,
374
+ adapters,
375
+ failingAdapters: readiness.failingAdapters,
376
+ unusableAdapters: sdkReadiness.unusableAdapters,
377
+ invocation: replayInvocation(argv, args, input),
378
+ ephemeralNpx: options.ephemeralNpx,
379
+ });
380
+ const releaseError = await releaseLease(lease);
381
+ if (releaseError !== undefined) {
382
+ await writeStream(
383
+ stderr,
384
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
385
+ );
386
+ return { code: EXIT.turn };
387
+ }
388
+ return { code: EXIT.argument };
318
389
  }
319
390
 
320
- // PBCLI-39: a resume rebuilds the stored lineup, so it needs the same
321
- // SDKs — an install that lost one must not resume into a mid-turn error.
322
- const sdkError = await adapterSdksDiagnostic(
323
- [...roleSpecs.values(), record.captain],
324
- ctx,
325
- stdinReplayArgs(args, reply),
326
- );
327
- if (sdkError !== undefined) {
328
- stderr.write(sdkError);
329
- return { code: EXIT.arg };
391
+ if (lease === undefined) {
392
+ try {
393
+ throwIfAborted(options.signal);
394
+ lease = await store.acquire(sessionId);
395
+ throwIfAborted(options.signal);
396
+ } catch (error) {
397
+ const releaseError = await releaseLease(lease);
398
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
399
+ if (releaseError !== undefined) {
400
+ await writeStream(
401
+ stderr,
402
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
403
+ );
404
+ return { code: EXIT.turn };
405
+ }
406
+ return { code: EXIT.argument };
407
+ }
330
408
  }
331
409
 
332
- let runtime;
410
+ let attemptId;
333
411
  try {
334
- runtime = entry.createRuntime({
335
- captainOptions: record.option,
336
- players: playersFromSpecs(roleSpecs),
337
- });
412
+ attemptId = createAttemptId(options);
338
413
  } catch (error) {
339
- stderr.write(`playbook run: ${message(error)}\n`);
340
- return { code: EXIT.arg };
341
- }
342
- if (typeof runtime.restore !== 'function') {
343
- stderr.write(
344
- `playbook run: the ${record.from} runtime does not support resume\n`,
345
- );
346
- return { code: EXIT.arg };
347
- }
348
-
349
- return driveTurn({
350
- ctx,
351
- runtime,
352
- store: record,
353
- text: reply,
354
- json: args.json,
355
- verbose: args.verbose,
356
- restoreFrom: { snapshot: record.snapshot, sessionFile },
357
- });
358
- }
359
-
360
- // PBCLI-18: shared module-load pipeline for `<from>` and a stored resume
361
- // specifier. Returns { entry } or an { code } failure already reported.
362
- async function loadRegistryEntry(specifier, { loadModule, stderr }) {
363
- let entry;
364
- try {
365
- entry = (await loadModule(specifier))?.default;
366
- } catch (cause) {
367
- stderr.write(
368
- `playbook run: ${specifier} failed to import: ${message(cause)}\n`,
369
- );
370
- return { code: EXIT.arg };
371
- }
372
- if (!isValidRegistryEntry(entry)) {
373
- stderr.write(
374
- `playbook run: ${specifier} exposes no valid registry entry\n`,
375
- );
376
- return { code: EXIT.arg };
377
- }
378
- return { entry };
379
- }
380
-
381
- // PBCLI-20/23: one Boss turn over the headless cligent-backed ports,
382
- // parking to the session store when the playbook awaits a Boss reply.
383
- async function driveTurn({ ctx, runtime, store, text, json, verbose, restoreFrom }) {
384
- const { stdout, stderr, createAgent } = ctx;
385
- const { sessionId, cwd } = store;
386
-
387
- const agentsByRole = new Map();
388
- for (const [role, spec] of Object.entries(store.players)) {
389
- agentsByRole.set(role, createAgent({ ...spec, role, cwd }));
414
+ const releaseError = await releaseLease(lease);
415
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
416
+ if (releaseError !== undefined) {
417
+ await writeStream(
418
+ stderr,
419
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
420
+ );
421
+ return { code: EXIT.turn };
422
+ }
423
+ return { code: EXIT.argument };
390
424
  }
391
- const captainAgent = createAgent({ ...store.captain, role: 'captain', cwd });
392
-
393
- const controller = new AbortController();
394
- const ports = {
395
- async callPlayer(playerId, prompt, signal, callOptions) {
396
- const agent = agentsByRole.get(playerId);
397
- if (!agent) return { status: 'error', error: `unknown player ${playerId}` };
398
- const result = await agent.run(prompt, { resume: callOptions?.resume, signal });
399
- return toPlayerResult(result);
400
- },
401
- async callCaptain(prompt, signal, callOptions) {
402
- const result = await captainAgent.run(prompt, {
403
- resume: callOptions?.resume,
404
- ...(callOptions?.allowedTools === undefined
405
- ? {}
406
- : { allowedTools: callOptions.allowedTools }),
407
- signal,
408
- });
409
- return {
410
- status: result.status,
411
- ...(result.finalText === undefined
412
- ? {}
413
- : { finalText: result.finalText }),
414
- ...(result.error ? { error: result.error } : {}),
415
- };
416
- },
417
- async callJudge(prompt, signal) {
418
- // CAPTAIN-9 / DR-013 A1: wrap every judge prompt in the shared
419
- // hidden-control envelope. Runtime judge prompts embed raw Boss text
420
- // and quoted player output, so the envelope is what makes them
421
- // delimited evidence rather than instructions — and it is the
422
- // prompt-level isolation that stands in for provider enforcement
423
- // when the tool allowlist below has to be omitted.
424
- const result = await captainAgent.run(hiddenControlEnvelope(prompt), {
425
- resume: false,
426
- // An empty allowlist means "no tools" and is distinct from omission,
427
- // which grants the adapter's full tool surface. Send it only where
428
- // the adapter can enforce it; codex rejects any tool list outright,
429
- // so requesting one would fail every judge call.
430
- ...controlCallToolOptions(store.captain.adapter),
431
- signal,
432
- });
433
- if (result.status !== 'ok' || result.finalText === undefined) {
434
- throw new Error(result.error ?? 'judge call failed');
435
- }
436
- return result.finalText;
437
- },
438
- async callPlaybook() {
439
- // The one-shot host cannot drive the child, but returning a suspended
440
- // start lets the linked runtime expose that boundary as outcome
441
- // `suspended`, which finishRun maps to the documented exit code 3.
442
- return { state: 'suspended', childSessionId: randomUUID() };
443
- },
444
- async emitStatus(statusText) {
445
- stderr.write(`◇ ${statusText}\n`);
446
- },
447
- async emitTelemetry(event) {
448
- if (verbose) stderr.write(`· ${event.topic}\n`);
449
- },
450
- };
451
425
 
452
- const session = {
453
- sessionId,
454
- playbookId: store.playbookId,
455
- rootSessionId: sessionId,
456
- depth: 0,
457
- ports,
458
- };
459
- // DR-014 §2: only a successfully persisted parked hand-off skips
460
- // disposal; the session is then suspended, not ended.
461
- let parked = false;
426
+ let settled;
462
427
  try {
463
- if (restoreFrom) {
464
- try {
465
- await runtime.restore(session, restoreFrom.snapshot);
466
- } catch (error) {
467
- stderr.write(
468
- `playbook run: session ${sessionId} cannot be resumed: ${message(error)}\n`,
469
- );
470
- return { code: EXIT.arg };
471
- }
472
- } else {
473
- await runtime.init(session);
474
- }
475
- const result = await runtime.handleBossInput({
476
- text,
477
- signal: controller.signal,
428
+ settled = await driveHeadlessCaptainTurn({
429
+ config,
430
+ input,
431
+ sessionId,
432
+ cwd,
433
+ loadModule,
434
+ stderr,
435
+ verbose: args.verbose,
436
+ ...(options.adapterImports
437
+ ? { adapterImports: options.adapterImports }
438
+ : {}),
439
+ ...(options.createCaptainRuntime
440
+ ? { createCaptainRuntime: options.createCaptainRuntime }
441
+ : {}),
442
+ ...(options.createCaptainSessionId
443
+ ? { createCaptainSessionId: options.createCaptainSessionId }
444
+ : {}),
445
+ ...(options.createHostRuntime
446
+ ? { createHostRuntime: options.createHostRuntime }
447
+ : {}),
448
+ ...(restoreSnapshot !== undefined
449
+ ? { restoreSnapshot }
450
+ : {}),
451
+ ...(options.signal ? { signal: options.signal } : {}),
452
+ beforeBossTurn: async (baselineSnapshot) => {
453
+ throwIfAborted(options.signal);
454
+ return args.retryUncertain
455
+ ? lease.beginRetry({
456
+ expectedAttemptId: priorRecord.uncertain.attemptId,
457
+ nextAttemptId: attemptId,
458
+ })
459
+ : lease.beginTurn({
460
+ input,
461
+ attemptId,
462
+ attemptedExecutionProjection: config,
463
+ ...(priorRecord === undefined
464
+ ? {
465
+ fresh: {
466
+ cwd,
467
+ structuralProjection:
468
+ projectCaptainSessionStructure(config),
469
+ snapshot: baselineSnapshot,
470
+ },
471
+ }
472
+ : {}),
473
+ });
474
+ },
475
+ assertBeforeBossTurn: () => lease.assertOwner(),
478
476
  });
479
- const parkedSnapshot =
480
- (result.outcome === 'quiescent' || result.outcome === 'no-action') &&
481
- typeof runtime.exportSnapshot === 'function'
482
- ? runtime.exportSnapshot()
483
- : undefined;
484
- if (parkedSnapshot && parkedSnapshot.pendingBossQuestions.length > 0) {
485
- const outcome = await finishParked({
486
- ctx,
487
- store,
488
- snapshot: parkedSnapshot,
489
- json,
490
- });
491
- parked = outcome.code === EXIT.suspended;
492
- return outcome;
493
- }
494
- const outcome = finishRun(result, { stdout, stderr, json, sessionId });
495
- if (restoreFrom && result.outcome === 'terminal') {
496
- // The turn succeeded; a session-file removal failure must not mask
497
- // the terminal output or flip the exit code.
498
- try {
499
- await rm(restoreFrom.sessionFile, { force: true });
500
- } catch (error) {
501
- stderr.write(
502
- `playbook run: warning: could not remove ${restoreFrom.sessionFile}: ${message(error)}\n`,
503
- );
504
- }
505
- }
506
- return outcome;
507
477
  } catch (error) {
508
- stderr.write(`playbook run: ${message(error)}\n`);
509
- return { code: EXIT.failed };
510
- } finally {
511
- if (!parked) {
512
- try {
513
- await runtime.dispose();
514
- } catch {
515
- // A dispose failure must not mask the run's own outcome.
516
- }
478
+ const cleanupIncomplete = isCaptainSessionHostCleanupIncomplete(error);
479
+ const releaseError = cleanupIncomplete
480
+ ? undefined
481
+ : await releaseLease(lease);
482
+ await writeStream(stderr, `playbook run: ${message(error)}\n`);
483
+ if (cleanupIncomplete) {
484
+ await writeStream(
485
+ stderr,
486
+ 'playbook run: writer lease retained until process exit because host cleanup was incomplete\n',
487
+ );
517
488
  }
489
+ if (releaseError !== undefined) {
490
+ await writeStream(
491
+ stderr,
492
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
493
+ );
494
+ }
495
+ return {
496
+ code:
497
+ error instanceof HeadlessHostSetupError && releaseError === undefined
498
+ ? EXIT.argument
499
+ : EXIT.turn,
500
+ };
518
501
  }
519
- }
520
502
 
521
- // PBCLI-23: persist the parked session and surface the pending question —
522
- // stdout carries the question text (the run's product), stderr one hint
523
- // naming the session id and the exact resume command.
524
- async function finishParked({ ctx, store, snapshot, json }) {
525
- const { stdout, stderr } = ctx;
526
- const now = new Date().toISOString();
527
- const record = {
528
- ...store,
529
- createdAt: store.createdAt ?? now,
530
- updatedAt: now,
531
- snapshot,
532
- };
533
- const file = join(ctx.sessionsDir, `${store.sessionId}.json`);
503
+ let durableRecord;
534
504
  try {
535
- await mkdir(ctx.sessionsDir, { recursive: true, mode: 0o700 });
536
- // Write-then-rename so a crash mid-write can never truncate the only
537
- // durable copy of the session.
538
- const tmpFile = `${file}.${process.pid}.tmp`;
539
- await writeFile(tmpFile, `${JSON.stringify(record, null, 2)}\n`, {
540
- mode: 0o600,
505
+ throwIfAborted(options.signal);
506
+ durableRecord = await lease.settle({
507
+ attemptId: settled.uncertainRecord.uncertain.attemptId,
508
+ snapshot: settled.snapshot,
541
509
  });
542
- await rename(tmpFile, file);
543
510
  } catch (error) {
544
- stderr.write(`playbook run: cannot persist session: ${message(error)}\n`);
545
- return { code: EXIT.failed };
546
- }
547
- const questions = snapshot.pendingBossQuestions;
548
- if (json) {
549
- stdout.write(
550
- `${JSON.stringify(
551
- {
552
- outcome: 'awaiting-reply',
553
- sessionId: store.sessionId,
554
- questions: questions.map(({ questionId, player, question }) => ({
555
- questionId,
556
- player,
557
- question,
558
- })),
559
- },
560
- null,
561
- 2,
562
- )}\n`,
511
+ let cleanupError;
512
+ try {
513
+ await settled.dispose();
514
+ } catch (cause) {
515
+ cleanupError = cause;
516
+ }
517
+ await writeStream(
518
+ stderr,
519
+ `playbook run: cannot persist Captain session: ${message(error)}\n`,
563
520
  );
564
- } else {
565
- stdout.write(`${questions.map(({ question }) => question).join('\n')}\n`);
566
- }
567
- stderr.write(
568
- `playbook run: session ${store.sessionId} is awaiting a Boss reply; ` +
569
- `continue with: playbook run resume ${store.sessionId} "<answer>"\n`,
570
- );
571
- return { code: EXIT.suspended };
572
- }
573
-
574
- // PBCLI-18: map the single turn's outcome to stdout output and an exit code.
575
- function finishRun(result, { stdout, stderr, json, sessionId }) {
576
- switch (result.outcome) {
577
- case 'terminal':
578
- stdout.write(
579
- (json
580
- ? JSON.stringify(
581
- {
582
- outcome: 'terminal',
583
- sessionId,
584
- output: result.output ?? null,
585
- },
586
- null,
587
- 2,
588
- )
589
- : renderOutput(result.output)) + '\n',
521
+ if (cleanupError !== undefined) {
522
+ const cleanupFailure = new CaptainSessionHostCleanupError(
523
+ [error, cleanupError],
524
+ `Captain session settlement failed (${message(error)}) and host cleanup also failed: ${message(cleanupError)}`,
590
525
  );
591
- return { code: EXIT.terminal };
592
- case 'failed':
593
- case 'aborted':
594
- stderr.write(
595
- `playbook run: ${result.outcome}${
596
- result.error ? `: ${result.error.message}` : ''
597
- }\n`,
526
+ await writeStream(
527
+ stderr,
528
+ `playbook run: ${message(cleanupFailure)}\n`,
598
529
  );
599
- return { code: EXIT.failed };
600
- case 'suspended':
601
- stderr.write(
602
- 'playbook run: playbook made a nested call a one-shot run cannot answer\n',
530
+ await writeStream(
531
+ stderr,
532
+ 'playbook run: writer lease retained until process exit because host cleanup was incomplete\n',
603
533
  );
604
- return { code: EXIT.suspended };
605
- default:
606
- // quiescent / no-action without a persistable pending question:
607
- // the pre-DR-014 diagnostic path.
608
- stderr.write(
609
- 'playbook run: playbook is awaiting Boss input; a one-shot run cannot continue\n',
534
+ return { code: EXIT.turn };
535
+ }
536
+ const releaseError = await releaseLease(lease);
537
+ if (releaseError !== undefined) {
538
+ await writeStream(
539
+ stderr,
540
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
610
541
  );
611
- return { code: EXIT.suspended };
542
+ }
543
+ return { code: EXIT.turn };
612
544
  }
613
- }
614
545
 
615
- function renderOutput(output) {
616
- if (output === null || output === undefined) return '';
617
- if (typeof output === 'string') return output;
618
- if (
619
- typeof output === 'object' &&
620
- typeof (output.response ?? output.finalText) === 'string'
621
- ) {
622
- return output.response ?? output.finalText;
546
+ const releaseError = await releaseLease(lease);
547
+ lease = undefined;
548
+ if (releaseError !== undefined) {
549
+ await writeStream(
550
+ stderr,
551
+ `playbook run: cannot release Captain session lease: ${message(releaseError)}\n`,
552
+ );
553
+ return { code: EXIT.turn };
554
+ }
555
+ if (options.signal?.aborted) {
556
+ await writeStream(
557
+ stderr,
558
+ 'playbook run: Captain turn was interrupted; reply withheld\n',
559
+ );
560
+ return { code: EXIT.turn, sessionId, record: durableRecord };
623
561
  }
624
- return JSON.stringify(output);
625
- }
626
562
 
627
- function toPlayerResult(result) {
563
+ // Durable hand-off transfers semantic ownership to the logical session.
564
+ // Process exit owns ephemeral transport teardown; semantic disposal here
565
+ // would end the session that `--continue` must restore.
566
+ try {
567
+ await presentHeadlessCaptainTurn(settled, {
568
+ stdout,
569
+ json: args.json,
570
+ });
571
+ } catch (error) {
572
+ await writeStream(
573
+ stderr,
574
+ `playbook run: cannot write Captain reply: ${message(error)}\n`,
575
+ );
576
+ return { code: EXIT.turn };
577
+ }
628
578
  return {
629
- status: result.status,
630
- ...(result.finalText === undefined ? {} : { finalText: result.finalText }),
631
- ...(result.resumeToken ? { resumeToken: result.resumeToken } : {}),
632
- ...(result.error ? { error: result.error } : {}),
579
+ code: EXIT.ok,
580
+ sessionId,
581
+ reply: settled.reply,
582
+ snapshot: settled.snapshot,
583
+ config: settled.config,
584
+ cwd: settled.cwd,
585
+ record: durableRecord,
633
586
  };
634
587
  }
635
588
 
636
- function playersFromSpecs(roleSpecs) {
637
- return [...roleSpecs].map(([role, spec]) => ({
638
- id: role,
639
- adapter: spec.adapter,
640
- ...(spec.model ? { model: spec.model } : {}),
641
- }));
642
- }
643
-
644
- // DR-013 A1: adapters with no provider-enforced tool-restriction surface.
645
- // Cligent's codex adapter rejects any allowedTools value — including the
646
- // empty list that expresses tool-free — so a control call that requests one
647
- // fails before the model is reached. Omission is the only way such an
648
- // adapter can run a control call; isolation then rests on the prompt.
649
- const ADAPTERS_WITHOUT_TOOL_ENFORCEMENT = new Set(['codex']);
650
-
651
- // Keep requesting enforcement whenever the adapter is unknown, so the
652
- // DR-013 guarantee holds by default.
653
- function controlCallToolOptions(captainAdapter) {
654
- if (ADAPTERS_WITHOUT_TOOL_ENFORCEMENT.has(captainAdapter)) return {};
655
- return { allowedTools: [] };
656
- }
657
-
658
- // PBCLI-19/26: returns a diagnostic for the first invalid spec — an
659
- // unknown adapter or an effort the adapter does not support — or
660
- // undefined when every spec resolves. The caller must compare against
661
- // undefined.
662
- function specsDiagnostic(specs) {
663
- for (const spec of specs) {
589
+ // PBCLI-20: run exactly one Boss boundary and capture its one accepted reply
590
+ // plus the complete shell snapshot. No stdout presentation occurs here.
591
+ export async function driveHeadlessCaptainTurn({
592
+ config,
593
+ input,
594
+ sessionId,
595
+ cwd,
596
+ loadModule,
597
+ stderr,
598
+ verbose = false,
599
+ adapterImports,
600
+ createCaptainRuntime,
601
+ createCaptainSessionId,
602
+ createHostRuntime = createTmuxPlayRuntime,
603
+ restoreSnapshot,
604
+ beforeBossTurn,
605
+ assertBeforeBossTurn,
606
+ signal,
607
+ }) {
608
+ const replies = [];
609
+ let shell;
610
+ let host;
611
+ let baselineSnapshot;
612
+ let uncertainRecord;
613
+ try {
614
+ try {
615
+ const created = await createCaptainSessionHost({
616
+ config,
617
+ sessionId,
618
+ cwd,
619
+ loadModule,
620
+ observers: [
621
+ {
622
+ async onRecord(record) {
623
+ if (record.type === 'captain_reply') {
624
+ replies.push(record.text);
625
+ } else if (record.type === 'captain_status') {
626
+ await writeStream(stderr, `${record.message}\n`);
627
+ } else if (verbose && record.type === 'captain_telemetry') {
628
+ await writeStream(stderr, `\u00b7 ${record.topic}\n`);
629
+ }
630
+ },
631
+ },
632
+ ],
633
+ ...(signal ? { signal } : {}),
634
+ ...(adapterImports ? { adapterImports } : {}),
635
+ ...(createCaptainRuntime ? { createCaptainRuntime } : {}),
636
+ ...(createCaptainSessionId ? { createCaptainSessionId } : {}),
637
+ createHostRuntime,
638
+ ...(restoreSnapshot !== undefined ? { restoreSnapshot } : {}),
639
+ });
640
+ ({ shell, host, snapshot: baselineSnapshot } = created);
641
+ uncertainRecord = await beforeBossTurn?.(baselineSnapshot);
642
+ } catch (error) {
643
+ throw new HeadlessHostSetupError(error);
644
+ }
645
+ await assertBeforeBossTurn?.();
646
+ if (signal?.aborted) {
647
+ throw signal.reason ?? new Error('Captain turn aborted');
648
+ }
649
+ await host.runBossTurn(input);
650
+ throwIfAborted(signal);
651
+ if (
652
+ replies.length !== 1 ||
653
+ typeof replies[0] !== 'string' ||
654
+ replies[0].trim().length === 0
655
+ ) {
656
+ throw new Error(
657
+ `Captain turn produced ${replies.length} usable Boss-visible replies; expected exactly one`,
658
+ );
659
+ }
660
+ if (shell === undefined) {
661
+ throw new Error('Captain shell host initialized without a shell');
662
+ }
663
+ const snapshot = shell.exportSnapshot();
664
+ if (snapshot === undefined) {
665
+ throw new Error('Captain turn settled without an exportable session snapshot');
666
+ }
664
667
  if (
665
- !isAgentSpec(spec) ||
666
- !Object.prototype.hasOwnProperty.call(ADAPTER_LOADERS, spec.adapter)
668
+ snapshot.captain?.sessionId === sessionId ||
669
+ snapshot.issuedSessionIds?.includes(sessionId)
667
670
  ) {
668
- const adapter = isAgentSpec(spec)
669
- ? spec.adapter
670
- : String(spec?.adapter);
671
- return `unknown adapter "${adapter}"`;
671
+ throw new Error(
672
+ 'logical session id collided with an internal Captain session id',
673
+ );
672
674
  }
673
- if (spec.effort !== undefined && !isEffortSupported(spec.adapter, spec.effort)) {
674
- const supported = supportedEffortValues(spec.adapter).join(', ');
675
- return `adapter "${spec.adapter}" does not support effort "${spec.effort}" (supported: ${supported})`;
675
+ return {
676
+ sessionId,
677
+ reply: replies[0],
678
+ snapshot,
679
+ config: cloneJson(config),
680
+ cwd,
681
+ uncertainRecord,
682
+ dispose: () => host.dispose(),
683
+ };
684
+ } catch (error) {
685
+ if (host !== undefined) {
686
+ try {
687
+ await host.dispose();
688
+ } catch (cleanupError) {
689
+ const cleanupFailure = new CaptainSessionHostCleanupError(
690
+ [error, cleanupError],
691
+ `Captain session turn failed (${message(error)}) and host cleanup also failed: ${message(cleanupError)}`,
692
+ );
693
+ throw error instanceof HeadlessHostSetupError
694
+ ? new HeadlessHostSetupError(cleanupFailure)
695
+ : cleanupFailure;
696
+ }
676
697
  }
698
+ throw error;
677
699
  }
678
- return undefined;
679
700
  }
680
701
 
681
- // PBCLI-40: the replay tail for input the command consumed from stdin
682
- // the pipe that carried it will not exist when the printed command runs.
683
- // The value rides behind a `--` end-of-options terminator, because quoting
684
- // alone cannot keep a flag-shaped value (`--json`, `--last`, a `-`-leading
685
- // bullet) from being read as an option; where the original invocation
686
- // already activated a terminator of its own, that one is reused — a second
687
- // `--` after the first would itself be positional data on the replay,
688
- // turning a `--json` task into `-- --json`.
689
- function stdinReplayArgs(args, resolved) {
690
- if (args.task !== undefined) return [];
691
- return args.terminated ? [resolved] : ['--', resolved];
692
- }
693
-
694
- // PBCLI-39/40: returns the ready-to-write stderr block naming every bound
695
- // adapter whose optional-peer SDK is not installed, or undefined when every
696
- // one of them loads. Runs only after specsDiagnostic has accepted the
697
- // adapter names, so every spec here carries a known adapter.
698
- async function adapterSdksDiagnostic(specs, ctx, stdinArgs = []) {
699
- const adapters = specs.map((spec) => spec.adapter);
700
- const { unusableAdapters } = await checkAdapterSdks(
701
- adapters,
702
- ctx.probeAdapterSdk,
703
- ...(ctx.classifyRuntime ? [ctx.classifyRuntime] : []),
704
- );
705
- if (unusableAdapters.length === 0) return undefined;
706
- const [header, ...commands] = adapterSdkFailureLines(unusableAdapters, {
707
- // PBCLI-40: the ephemeral re-run carries the lineup's full mapped SDK
708
- // set and the original arguments, so it completes in one hop and runs
709
- // exactly as printed. stdinArgs arrive terminator-ready from
710
- // stdinReplayArgs — appended verbatim here, because whether a `--` is
711
- // needed depends on the original invocation's own parse state.
712
- requiredSdks: mappedSdksFor(adapters),
713
- invocation: [...ctx.rawArgv, ...stdinArgs],
714
- ...(ctx.ephemeralNpx !== undefined
715
- ? { ephemeralNpx: ctx.ephemeralNpx }
716
- : {}),
717
- }).filter((line) => line !== '');
718
- // Only the header takes the command prefix; the install lines stay
719
- // copy-pasteable.
720
- return [`playbook run: ${header}`, ...commands]
721
- .map((line) => `${line}\n`)
722
- .join('');
702
+ // PBCLI-20/49: both presentations construct the same shell and cligent core.
703
+ // A caller supplies only observers and lifecycle ownership; the configured
704
+ // Captain, referenced-player roster, working directory, and restore boundary
705
+ // remain one implementation.
706
+ export class CaptainSessionHostCleanupError extends AggregateError {
707
+ constructor(errors, messageText) {
708
+ super(errors, messageText);
709
+ this.name = 'CaptainSessionHostCleanupError';
710
+ this.code = 'PLAYBOOK_CAPTAIN_HOST_CLEANUP_INCOMPLETE';
711
+ }
723
712
  }
724
713
 
725
- function isAgentSpec(spec) {
714
+ function isCaptainSessionHostCleanupIncomplete(error) {
715
+ if (error instanceof CaptainSessionHostCleanupError) return true;
726
716
  return (
727
- typeof spec === 'object' &&
728
- spec !== null &&
729
- typeof spec.adapter === 'string' &&
730
- spec.adapter.length > 0 &&
731
- (spec.model === undefined || typeof spec.model === 'string') &&
732
- (spec.effort === undefined ||
733
- (typeof spec.effort === 'string' && spec.effort.length > 0))
717
+ error instanceof HeadlessHostSetupError &&
718
+ isCaptainSessionHostCleanupIncomplete(error.cause)
734
719
  );
735
720
  }
736
721
 
737
- // PBCLI-29: the run host reads the same user config file the interactive
738
- // launcher resolves. The resolver is imported lazily: a static import of
739
- // ./playbook.js would deadlock the CLI entry — playbook.js is still
740
- // mid-evaluation of its own top-level await when it dynamically imports
741
- // this module, and a circular static edge back to it can never settle.
742
- // The launcher always injects userConfigPath, so this default runs only
743
- // for direct runPlaybookRun callers, where playbook.js is not evaluating.
744
- async function defaultUserConfigPath() {
745
- const { resolveUserConfigPath } = await import('./playbook.js');
746
- return resolveUserConfigPath(process.env, process.env.HOME ?? homedir());
747
- }
748
-
749
- // PBCLI-28/29 (DR-017): default agent specs for a first run, read from the
750
- // user config's top-level `run` map. An absent file or absent map is an
751
- // empty default set; a malformed file or block fails closed — the run must
752
- // never silently bind different agents than the user configured. Adapter
753
- // and effort support of the specs actually bound flow through the shared
754
- // specsDiagnostic path.
755
- async function loadRunDefaults(userConfigPath) {
756
- const defaults = { players: new Map() };
757
- let text;
758
- try {
759
- text = await readFile(userConfigPath, 'utf8');
760
- } catch (error) {
761
- if (error?.code === 'ENOENT') return defaults;
762
- throw new Error(`cannot read config ${userConfigPath}: ${message(error)}`);
763
- }
764
- let config;
722
+ export async function createCaptainSessionHost({
723
+ config,
724
+ sessionId,
725
+ cwd,
726
+ loadModule,
727
+ observers,
728
+ adapterImports,
729
+ createCaptainRuntime,
730
+ createCaptainSessionId,
731
+ createHostRuntime = createTmuxPlayRuntime,
732
+ restoreSnapshot,
733
+ signal,
734
+ }) {
735
+ const shell = createPlaybookCaptainShell(captainOptionsFromConfig(config), {
736
+ loadModule,
737
+ ...(createCaptainRuntime ? { createCaptainRuntime } : {}),
738
+ ...(createCaptainSessionId
739
+ ? { createSessionId: createCaptainSessionId }
740
+ : {}),
741
+ });
742
+ let host;
765
743
  try {
766
- config = parseYaml(text);
767
- } catch (error) {
768
- throw new Error(`cannot parse config ${userConfigPath}: ${message(error)}`);
769
- }
770
- const run = isPlainMap(config) ? config.run : undefined;
771
- if (run === undefined || run === null) return defaults;
772
- if (!isPlainMap(run)) {
773
- throw new Error(`${userConfigPath}: run must be a map of agent defaults`);
774
- }
775
- if (run.captain !== undefined) {
776
- defaults.captain = parseAgentDefault(run.captain, 'run.captain', userConfigPath);
777
- }
778
- if (run.player !== undefined) {
779
- defaults.player = parseAgentDefault(run.player, 'run.player', userConfigPath);
780
- }
781
- if (run.players !== undefined && run.players !== null) {
782
- if (!isPlainMap(run.players)) {
744
+ const captain = captainHostBoundary(shell, restoreSnapshot);
745
+ host = await createHostRuntime({
746
+ captain,
747
+ captainConfig: projectHostAgent(
748
+ config.captain,
749
+ 'Captain execution config.captain',
750
+ ),
751
+ players: config.players.map(({ id, ...agent }) => ({
752
+ id,
753
+ ...projectHostAgent(agent, `Captain execution config.players.${id}`),
754
+ })),
755
+ cwd,
756
+ observers,
757
+ ...(signal ? { signal } : {}),
758
+ ...(adapterImports ? { adapterImports } : {}),
759
+ });
760
+ const snapshot = shell.exportSnapshot();
761
+ if (snapshot === undefined) {
783
762
  throw new Error(
784
- `${userConfigPath}: run.players must be a map of <role>: <agent>`,
763
+ 'Captain shell initialized without an exportable session snapshot',
785
764
  );
786
765
  }
787
- for (const [role, value] of Object.entries(run.players)) {
788
- defaults.players.set(
789
- role,
790
- parseAgentDefault(value, `run.players.${role}`, userConfigPath),
766
+ if (
767
+ restoreSnapshot !== undefined &&
768
+ !isDeepStrictEqual(snapshot, restoreSnapshot)
769
+ ) {
770
+ throw new Error('restored Captain snapshot changed before the Boss turn');
771
+ }
772
+ if (sessionId !== undefined) {
773
+ assertLogicalSessionIdDistinct({ sessionId, snapshot });
774
+ }
775
+ return { shell, host, snapshot };
776
+ } catch (error) {
777
+ let cleanupError;
778
+ try {
779
+ if (host !== undefined) await host.dispose();
780
+ else await shell.dispose?.();
781
+ } catch (cause) {
782
+ cleanupError = cause;
783
+ }
784
+ if (cleanupError !== undefined) {
785
+ throw new CaptainSessionHostCleanupError(
786
+ [error, cleanupError],
787
+ `Captain session host construction failed (${message(error)}) and cleanup also failed: ${message(cleanupError)}`,
791
788
  );
792
789
  }
790
+ throw error;
793
791
  }
794
- return defaults;
795
792
  }
796
793
 
797
- function parseAgentDefault(value, key, userConfigPath) {
798
- if (typeof value !== 'string' || value.length === 0) {
799
- throw new Error(
800
- `${userConfigPath}: ${key} must be an <adapter>[:<model>][@<effort>] string`,
801
- );
802
- }
803
- try {
804
- return parseAgent(value);
805
- } catch (error) {
806
- throw new Error(`${userConfigPath}: ${key}: ${message(error)}`);
807
- }
794
+ // PBCLI-20: restoration enters through the host's one init boundary. The
795
+ // restored shell is fresh and receives restore instead of init, never both.
796
+ function captainHostBoundary(shell, restoreSnapshot) {
797
+ return {
798
+ init: (session) =>
799
+ restoreSnapshot === undefined
800
+ ? shell.init(session)
801
+ : shell.restore(session, restoreSnapshot),
802
+ handleBossTurn: (turn, context) => shell.handleBossTurn(turn, context),
803
+ prepareDispose: () => shell.prepareDispose?.(),
804
+ dispose: () => shell.dispose?.(),
805
+ };
808
806
  }
809
807
 
810
- function isPlainMap(value) {
811
- return typeof value === 'object' && value !== null && !Array.isArray(value);
808
+ // Host-neutral execution-only projection. It is detached from the frozen
809
+ // launch plan and intentionally excludes layout, theme, and notifications.
810
+ export function executionConfigFromPlan(plan) {
811
+ return validateCaptainSessionExecutionProjection({
812
+ schemaVersion: 2,
813
+ captain: plan.captain,
814
+ players: plan.players.map(({ id, agent }) => ({ id, ...agent })),
815
+ catalog: Object.fromEntries(
816
+ Object.entries(plan.catalog).map(([id, item]) => [
817
+ id,
818
+ {
819
+ id: item.id,
820
+ from: item.from,
821
+ manifestCommand: item.manifestCommand,
822
+ command: item.command,
823
+ intent: item.intent,
824
+ artifactSchema: item.artifactSchema,
825
+ requiredRoleIds: item.requiredRoleIds,
826
+ concurrentRoleSets: item.concurrentRoleSets,
827
+ roles: item.roles,
828
+ options: item.options,
829
+ },
830
+ ]),
831
+ ),
832
+ });
812
833
  }
813
834
 
814
- // PBCLI-23: the session store honors XDG_STATE_HOME at invocation time.
815
- export function defaultSessionsDir(env = process.env) {
816
- const stateHome =
817
- typeof env.XDG_STATE_HOME === 'string' && env.XDG_STATE_HOME.trim() !== ''
818
- ? env.XDG_STATE_HOME
819
- : join(homedir(), '.local', 'state');
820
- return join(stateHome, 'playbook', 'sessions');
821
- }
835
+ // PBCLI-22/23: uncertain retry consumes only its exact attempted execution
836
+ // projection. Pure record validation and structural compatibility precede the
837
+ // complete stored-catalog prepare-before-import transaction.
838
+ export async function validateFrozenExecutionConfig(
839
+ structuralProjection,
840
+ executionProjection,
841
+ { loadModule, prepareRegistryModule },
842
+ ) {
843
+ const config = assertCaptainSessionExecutionCompatible(
844
+ structuralProjection,
845
+ executionProjection,
846
+ );
847
+ const catalogItems = Object.entries(config.catalog);
822
848
 
823
- // PBCLI-23: `--last` selects by the record's own update timestamp, not
824
- // filesystem mtime. Unreadable or foreign .json files are skipped.
825
- async function latestSessionRecord(sessionsDir) {
826
- let names;
827
- try {
828
- names = await readdir(sessionsDir);
829
- } catch {
830
- return undefined;
849
+ // Preserve the complete-catalog preparation transaction: prepare every
850
+ // stored canonical module before importing any. A hook may provision the
851
+ // module's dependencies but cannot rewrite the frozen module identity.
852
+ for (const [id, item] of catalogItems) {
853
+ if (prepareRegistryModule === undefined) continue;
854
+ let prepared;
855
+ try {
856
+ prepared = await prepareRegistryModule({
857
+ id,
858
+ from: item.from,
859
+ authoredFrom: item.from,
860
+ });
861
+ } catch (cause) {
862
+ throw new Error(`stored playbook ${JSON.stringify(id)} failed to prepare: ${message(cause)}`);
863
+ }
864
+ if (prepared !== undefined && prepared !== item.from) {
865
+ throw new Error(
866
+ `stored playbook ${JSON.stringify(id)} preparation changed its frozen module identity`,
867
+ );
868
+ }
831
869
  }
832
- const candidates = await Promise.all(
833
- names
834
- .filter((name) => name.endsWith('.json'))
835
- .map(async (name) => {
836
- const file = join(sessionsDir, name);
837
- try {
838
- const record = JSON.parse(await readFile(file, 'utf8'));
839
- if (!isValidSessionRecord(record)) return undefined;
840
- if (typeof record.updatedAt !== 'string') return undefined;
841
- return { file, record };
842
- } catch {
843
- return undefined;
844
- }
845
- }),
846
- );
847
- let latest;
848
- for (const candidate of candidates) {
849
- if (!candidate) continue;
850
- if (!latest || candidate.record.updatedAt > latest.record.updatedAt) {
851
- latest = candidate;
870
+
871
+ for (const [id, item] of catalogItems) {
872
+ let entry;
873
+ try {
874
+ entry = (await loadModule(item.from))?.default;
875
+ } catch (cause) {
876
+ throw new Error(`stored playbook ${JSON.stringify(id)} failed to import: ${message(cause)}`);
877
+ }
878
+ if (!isValidRegistryEntry(entry)) {
879
+ throw new Error(`stored playbook ${JSON.stringify(id)} exposes no valid registry entry`);
880
+ }
881
+ if (
882
+ entry.id !== id ||
883
+ entry.command !== item.manifestCommand ||
884
+ entry.intent !== item.intent ||
885
+ entry.artifactSchema !== item.artifactSchema ||
886
+ !isDeepStrictEqual(entry.requiredRoleIds, item.requiredRoleIds) ||
887
+ !isDeepStrictEqual(
888
+ entry.concurrentRoleSets,
889
+ item.concurrentRoleSets,
890
+ )
891
+ ) {
892
+ throw new Error(
893
+ `stored playbook ${JSON.stringify(id)} no longer matches its recorded manifest identity`,
894
+ );
852
895
  }
853
896
  }
854
- return latest;
897
+ return config;
855
898
  }
856
899
 
857
- function isValidSessionRecord(record) {
858
- return (
859
- typeof record === 'object' &&
860
- record !== null &&
861
- record.schemaVersion === SESSION_STORE_VERSION &&
862
- typeof record.sessionId === 'string' &&
863
- SESSION_REF_PATTERN.test(record.sessionId) &&
864
- typeof record.playbookId === 'string' &&
865
- typeof record.from === 'string' &&
866
- typeof record.cwd === 'string' &&
867
- isAgentSpec(record.captain) &&
868
- typeof record.players === 'object' &&
869
- record.players !== null &&
870
- Object.values(record.players).every(isAgentSpec) &&
871
- typeof record.option === 'object' &&
872
- record.option !== null &&
873
- typeof record.snapshot === 'object' &&
874
- record.snapshot !== null
875
- );
900
+ function adaptersFromExecutionConfig(config) {
901
+ return [
902
+ ...new Set([
903
+ config.captain.adapter,
904
+ ...config.players.map((player) => player.adapter),
905
+ ]),
906
+ ];
876
907
  }
877
908
 
878
- // PBCLI-20: default agent — one lazily-built Cligent per role, run through
879
- // the same event drain as tmux-play's host.
880
- function defaultCreateAgent({ adapter, model, effort, cwd, role }) {
881
- let cligent;
882
- return {
883
- async run(prompt, callOptions) {
884
- if (!cligent) {
885
- const AdapterClass = await ADAPTER_LOADERS[adapter]();
886
- // Protected auto mode (as the seeded lineup uses, PBCLI-11) so a
887
- // one-shot run does not block on routine approval prompts.
888
- cligent = new Cligent(new AdapterClass(), {
889
- cwd,
890
- role,
891
- permissions: { mode: 'auto' },
892
- ...(model ? { model } : {}),
893
- ...(effort ? { effort } : {}),
894
- });
895
- }
896
- return runCligentCall(cligent, prompt, callOptions);
897
- },
898
- };
909
+ function assertLogicalSessionIdDistinct(record) {
910
+ if (
911
+ record.snapshot.captain?.sessionId === record.sessionId ||
912
+ (Array.isArray(record.snapshot.issuedSessionIds) &&
913
+ record.snapshot.issuedSessionIds.includes(record.sessionId))
914
+ ) {
915
+ throw new Error(
916
+ 'logical session id collides with an internal Captain session id',
917
+ );
918
+ }
899
919
  }
900
920
 
901
- export async function runCligentCall(cligent, prompt, callOptions = {}) {
902
- const { resume, allowedTools, signal } = callOptions;
903
- const gen = cligent.run(prompt, {
904
- ...(signal ? { abortSignal: signal } : {}),
905
- ...(resume !== undefined ? { resume } : {}),
906
- ...(allowedTools !== undefined ? { allowedTools: [...allowedTools] } : {}),
907
- });
908
- const textParts = [];
909
- let done;
910
- let lastError;
911
- let completed = false;
912
- try {
913
- for (;;) {
914
- let next;
915
- try {
916
- next = await gen.next();
917
- } catch (error) {
918
- return { status: signal?.aborted ? 'aborted' : 'error', error: message(error) };
919
- }
920
- if (next.done) {
921
- completed = true;
922
- break;
923
- }
924
- const event = next.value;
925
- if (event.type === 'text' && typeof event.payload?.content === 'string') {
926
- textParts.push(event.payload.content);
927
- } else if (
928
- event.type === 'text_delta' &&
929
- typeof event.payload?.delta === 'string'
930
- ) {
931
- textParts.push(event.payload.delta);
932
- }
933
- if (event.type === 'error') lastError = event.payload?.message;
934
- if (event.type === 'done') done = event.payload;
935
- }
936
- } finally {
937
- if (!completed) {
938
- try {
939
- await gen.return(undefined);
940
- } catch {
941
- // The original outcome is already captured.
942
- }
921
+ function memoizedModuleLoader(loadModule) {
922
+ const modules = new Map();
923
+ return (specifier) => {
924
+ if (!modules.has(specifier)) {
925
+ modules.set(specifier, Promise.resolve().then(() => loadModule(specifier)));
943
926
  }
927
+ return modules.get(specifier);
928
+ };
929
+ }
930
+
931
+ function isValidRegistryEntry(value) {
932
+ if (
933
+ value === null ||
934
+ typeof value !== 'object' ||
935
+ Array.isArray(value) ||
936
+ typeof value.id !== 'string' ||
937
+ value.id.trim().length === 0 ||
938
+ typeof value.command !== 'string' ||
939
+ value.command.trim().length === 0 ||
940
+ typeof value.intent !== 'string' ||
941
+ value.artifactSchema !== 2 ||
942
+ !Array.isArray(value.requiredRoleIds) ||
943
+ value.requiredRoleIds.some(
944
+ (role) => typeof role !== 'string' || role.trim().length === 0,
945
+ ) ||
946
+ new Set(value.requiredRoleIds).size !== value.requiredRoleIds.length ||
947
+ !Array.isArray(value.concurrentRoleSets) ||
948
+ typeof value.validateOptions !== 'function' ||
949
+ typeof value.createRuntime !== 'function'
950
+ ) {
951
+ return false;
944
952
  }
945
- const status = done ? mapStatus(done.status) : 'error';
946
- const finalText = done?.result ?? (textParts.length > 0 ? textParts.join('') : undefined);
953
+ const roles = new Set(value.requiredRoleIds);
954
+ return value.concurrentRoleSets.every(
955
+ (set) =>
956
+ Array.isArray(set) &&
957
+ set.length >= 2 &&
958
+ set.every((role) => typeof role === 'string' && roles.has(role)) &&
959
+ new Set(set).size === set.length,
960
+ );
961
+ }
962
+
963
+ export function captainOptionsFromConfig(config) {
947
964
  return {
948
- status,
949
- finalText,
950
- ...(done?.resumeToken ? { resumeToken: done.resumeToken } : {}),
951
- ...(status === 'error'
952
- ? { error: done?.result ?? lastError ?? 'agent run failed' }
965
+ playbooks: Object.fromEntries(
966
+ Object.entries(config.catalog).map(([id, item]) => [
967
+ id,
968
+ {
969
+ from: item.from,
970
+ command: item.command,
971
+ roles: cloneJson(item.roles),
972
+ options: cloneJson(item.options),
973
+ },
974
+ ]),
975
+ ),
976
+ sessionAgents: {
977
+ captain: cloneJson(config.captain),
978
+ players: Object.fromEntries(
979
+ config.players.map(({ id, ...agent }) => [id, cloneJson(agent)]),
980
+ ),
981
+ },
982
+ ...(typeof config.captain.adapter === 'string' &&
983
+ config.captain.adapter.length > 0
984
+ ? { captainAdapter: config.captain.adapter }
953
985
  : {}),
954
986
  };
955
987
  }
956
988
 
957
- function mapStatus(doneStatus) {
958
- if (doneStatus === 'success') return 'ok';
959
- if (doneStatus === 'interrupted') return 'aborted';
960
- return 'error';
989
+ export async function presentHeadlessCaptainTurn(
990
+ { sessionId, reply },
991
+ { stdout, json = false },
992
+ ) {
993
+ await writeStream(
994
+ stdout,
995
+ `${json ? JSON.stringify({ sessionId, reply }) : reply}\n`,
996
+ );
961
997
  }
962
998
 
963
999
  export function parseRunArgs(argv) {
964
- const args = {
965
- from: undefined,
966
- task: undefined,
967
- resume: false,
968
- sessionRef: undefined,
969
- last: false,
970
- players: new Map(),
971
- captain: undefined,
972
- option: {},
973
- cwd: undefined,
1000
+ const parsed = {
1001
+ input: undefined,
1002
+ withPaths: [],
1003
+ noProvision: false,
974
1004
  json: false,
975
1005
  verbose: false,
976
- noProvision: false,
1006
+ continue: false,
1007
+ sessionId: undefined,
1008
+ retryUncertain: false,
1009
+ discardUncertain: false,
977
1010
  help: false,
978
1011
  terminated: false,
979
1012
  };
980
1013
  const positionals = [];
981
- for (let i = 0; i < argv.length; i += 1) {
982
- const arg = argv[i];
983
- // PBCLI-40: end-of-options — everything after `--` is positional, so a
984
- // flag-shaped task or reply (a stdin-derived `--json`, a `- bullet`
985
- // line) survives the ephemeral re-run round trip instead of being
986
- // reinterpreted as an option. `terminated` records that this branch
987
- // fired — only a `--` the walk itself treats as the terminator counts,
988
- // never one consumed as an option's value (`--cwd --`) — so the re-run
989
- // builder can reuse an active terminator instead of doubling it.
1014
+ for (let index = 0; index < argv.length; index += 1) {
1015
+ const arg = argv[index];
990
1016
  if (arg === '--') {
991
- args.terminated = true;
992
- positionals.push(...argv.slice(i + 1));
1017
+ parsed.terminated = true;
1018
+ positionals.push(...argv.slice(index + 1));
993
1019
  break;
994
1020
  }
995
- if (arg === '--help' || arg === '-h') args.help = true;
996
- else if (arg === '--json') args.json = true;
997
- else if (arg === '--verbose') args.verbose = true;
998
- else if (arg === '--no-provision') args.noProvision = true;
999
- else if (arg === '--last') args.last = true;
1000
- else if (arg === '--cwd') args.cwd = takeValue(argv, (i += 1), '--cwd');
1001
- else if (arg === '--captain')
1002
- args.captain = parseAgent(takeValue(argv, (i += 1), '--captain'));
1003
- else if (arg === '--player') {
1004
- const [role, agent] = takePair(takeValue(argv, (i += 1), '--player'), '--player');
1005
- args.players.set(role, parseAgent(agent));
1006
- } else if (arg === '--option') {
1007
- const [key, value] = takePair(takeValue(argv, (i += 1), '--option'), '--option');
1008
- args.option[key] = value;
1021
+ if (arg === '--help' || arg === '-h') parsed.help = true;
1022
+ else if (arg === '--json') parsed.json = true;
1023
+ else if (arg === '--verbose') parsed.verbose = true;
1024
+ else if (arg === '--no-provision') parsed.noProvision = true;
1025
+ else if (arg === '--retry-uncertain') {
1026
+ if (parsed.retryUncertain) {
1027
+ throw new Error('--retry-uncertain may be specified only once');
1028
+ }
1029
+ parsed.retryUncertain = true;
1030
+ } else if (arg === '--discard-uncertain') {
1031
+ if (parsed.discardUncertain) {
1032
+ throw new Error('--discard-uncertain may be specified only once');
1033
+ }
1034
+ parsed.discardUncertain = true;
1035
+ }
1036
+ else if (arg === '--continue') {
1037
+ if (parsed.continue) throw new Error('--continue may be specified only once');
1038
+ parsed.continue = true;
1039
+ } else if (arg === '--session') {
1040
+ const value = argv[index + 1];
1041
+ if (value === undefined || value === '') {
1042
+ throw new Error('--session needs a UUID value');
1043
+ }
1044
+ if (parsed.sessionId !== undefined) {
1045
+ throw new Error('--session may be specified only once');
1046
+ }
1047
+ parsed.sessionId = value;
1048
+ index += 1;
1049
+ } else if (arg.startsWith('--session=')) {
1050
+ const value = arg.slice('--session='.length);
1051
+ if (value === '') throw new Error('--session needs a UUID value');
1052
+ if (parsed.sessionId !== undefined) {
1053
+ throw new Error('--session may be specified only once');
1054
+ }
1055
+ parsed.sessionId = value;
1056
+ } else if (arg === '--with') {
1057
+ const value = argv[index + 1];
1058
+ if (value === undefined || value === '') {
1059
+ throw new Error('--with needs a value');
1060
+ }
1061
+ parsed.withPaths.push(value);
1062
+ index += 1;
1063
+ } else if (arg.startsWith('--with=')) {
1064
+ const value = arg.slice('--with='.length);
1065
+ if (value === '') throw new Error('--with needs a value');
1066
+ parsed.withPaths.push(value);
1067
+ } else if (
1068
+ RETIRED_FLAGS.has(arg) ||
1069
+ [...RETIRED_FLAGS].some((flag) => arg.startsWith(`${flag}=`))
1070
+ ) {
1071
+ throw new Error(
1072
+ `${arg.split('=')[0]} was removed; configure the shared Captain session in playbook.config.yaml or a --with overlay`,
1073
+ );
1009
1074
  } else if (arg.startsWith('-')) {
1010
1075
  throw new Error(`unknown option ${arg}`);
1011
- } else positionals.push(arg);
1012
- }
1013
- // PBCLI-22: `playbook run resume <session-id>|--last [reply]`.
1014
- if (positionals[0] === 'resume') {
1015
- args.resume = true;
1016
- let rest = positionals.slice(1);
1017
- if (!args.last) {
1018
- args.sessionRef = rest[0];
1019
- rest = rest.slice(1);
1076
+ } else {
1077
+ positionals.push(arg);
1020
1078
  }
1021
- if (rest.length > 0) args.task = rest.join(' ');
1022
- return args;
1023
1079
  }
1024
- if (args.last) throw new Error('--last applies to `playbook run resume`');
1025
- args.from = positionals[0];
1026
- if (positionals.length > 1) args.task = positionals.slice(1).join(' ');
1027
- return args;
1080
+ if (positionals.length > 1) {
1081
+ throw new Error(
1082
+ 'expected at most one [input] argument; quote multi-word input as one shell argument',
1083
+ );
1084
+ }
1085
+ if (parsed.continue && parsed.sessionId !== undefined) {
1086
+ throw new Error('--continue and --session are mutually exclusive');
1087
+ }
1088
+ if (parsed.retryUncertain && parsed.discardUncertain) {
1089
+ throw new Error(
1090
+ '--retry-uncertain and --discard-uncertain are mutually exclusive',
1091
+ );
1092
+ }
1093
+ if (
1094
+ (parsed.retryUncertain || parsed.discardUncertain) &&
1095
+ parsed.sessionId === undefined
1096
+ ) {
1097
+ throw new Error(
1098
+ '--retry-uncertain and --discard-uncertain require --session <id>',
1099
+ );
1100
+ }
1101
+ if (
1102
+ (parsed.retryUncertain || parsed.discardUncertain) &&
1103
+ (parsed.continue || positionals.length > 0)
1104
+ ) {
1105
+ throw new Error(
1106
+ 'uncertain-turn recovery accepts only an explicit --session and no input',
1107
+ );
1108
+ }
1109
+ if (
1110
+ parsed.discardUncertain &&
1111
+ (parsed.json || parsed.verbose || parsed.noProvision)
1112
+ ) {
1113
+ throw new Error(
1114
+ '--discard-uncertain does not accept --json, --verbose, or --no-provision',
1115
+ );
1116
+ }
1117
+ if (
1118
+ (parsed.retryUncertain || parsed.discardUncertain) &&
1119
+ parsed.withPaths.length > 0
1120
+ ) {
1121
+ throw new Error('--with is unavailable during uncertain-turn recovery');
1122
+ }
1123
+ if (
1124
+ parsed.sessionId !== undefined &&
1125
+ !SESSION_ID_PATTERN.test(parsed.sessionId)
1126
+ ) {
1127
+ throw new Error('--session needs a canonical UUID value');
1128
+ }
1129
+ parsed.input = positionals[0];
1130
+ return parsed;
1028
1131
  }
1029
1132
 
1030
- // PBCLI-19: `<agent>` is `<adapter>[:<model>][@<effort>]`. The effort
1031
- // rides after the last `@` so a model name may itself contain colons
1032
- // (`opencode:ollama/llama3:8b@max`); `claude@high` keeps the default
1033
- // model while setting effort.
1034
- function parseAgent(value) {
1035
- const at = value.lastIndexOf('@');
1036
- const spec = at === -1 ? value : value.slice(0, at);
1037
- const effort = at === -1 ? undefined : value.slice(at + 1);
1038
- if (at !== -1 && !effort) {
1039
- throw new Error(`agent "${value}" has an empty effort after '@'`);
1133
+ async function reportReadinessFailure({
1134
+ stderr,
1135
+ adapters,
1136
+ failingAdapters,
1137
+ unusableAdapters,
1138
+ invocation,
1139
+ ephemeralNpx,
1140
+ }) {
1141
+ if (unusableAdapters.length > 0) {
1142
+ const lines = adapterSdkFailureLines(unusableAdapters, {
1143
+ requiredSdks: mappedSdksFor(adapters),
1144
+ invocation,
1145
+ ...(ephemeralNpx !== undefined ? { ephemeralNpx } : {}),
1146
+ });
1147
+ const [first, ...rest] = lines;
1148
+ await writeStream(
1149
+ stderr,
1150
+ [
1151
+ ...(first ? [`playbook run: ${first}`] : []),
1152
+ ...rest,
1153
+ ]
1154
+ .map((line) => `${line}\n`)
1155
+ .join(''),
1156
+ );
1157
+ }
1158
+ if (failingAdapters.length > 0) {
1159
+ await writeStream(
1160
+ stderr,
1161
+ `playbook run: adapters not ready: ${failingAdapters.join(', ')}\n`,
1162
+ );
1040
1163
  }
1041
- const colon = spec.indexOf(':');
1042
- const adapter = colon === -1 ? spec : spec.slice(0, colon);
1043
- const model = colon === -1 ? undefined : spec.slice(colon + 1);
1044
- return {
1045
- adapter,
1046
- ...(model ? { model } : {}),
1047
- ...(effort ? { effort } : {}),
1048
- };
1049
1164
  }
1050
1165
 
1051
- function takeValue(argv, index, flag) {
1052
- const value = argv[index];
1053
- if (value === undefined) throw new Error(`${flag} needs a value`);
1054
- return value;
1166
+ async function resolveBossInput(input, options, stderr) {
1167
+ let resolved = input;
1168
+ if (resolved === undefined) {
1169
+ try {
1170
+ resolved = await awaitWithAbort(
1171
+ (options.readStdin ?? readAllStdin)(),
1172
+ options.signal,
1173
+ );
1174
+ } catch (error) {
1175
+ await writeStream(
1176
+ stderr,
1177
+ `playbook run: cannot read stdin: ${message(error)}\n`,
1178
+ );
1179
+ return { ok: false };
1180
+ }
1181
+ }
1182
+ if (resolved.trim().length === 0) {
1183
+ await writeStream(
1184
+ stderr,
1185
+ 'playbook run: empty input; pass one argument or pipe a Boss message on stdin\n',
1186
+ );
1187
+ return { ok: false };
1188
+ }
1189
+ return { ok: true, input: resolved };
1055
1190
  }
1056
1191
 
1057
- function takePair(value, flag) {
1058
- const eq = value.indexOf('=');
1059
- if (eq <= 0) throw new Error(`${flag} needs <key>=<value>`);
1060
- return [value.slice(0, eq), value.slice(eq + 1)];
1192
+ async function awaitWithAbort(value, signal) {
1193
+ if (signal === undefined) return value;
1194
+ if (signal.aborted) throw signal.reason ?? new Error('operation aborted');
1195
+ let onAbort;
1196
+ const aborted = new Promise((_, reject) => {
1197
+ onAbort = () => reject(signal.reason ?? new Error('operation aborted'));
1198
+ signal.addEventListener('abort', onAbort, { once: true });
1199
+ });
1200
+ try {
1201
+ return await Promise.race([value, aborted]);
1202
+ } finally {
1203
+ signal.removeEventListener('abort', onAbort);
1204
+ }
1061
1205
  }
1062
1206
 
1063
- function registryImportSpecifier(specifier, cwd) {
1064
- if (
1065
- isAbsolute(specifier) ||
1066
- specifier.startsWith('./') ||
1067
- specifier.startsWith('../') ||
1068
- specifier.startsWith('.\\') ||
1069
- specifier.startsWith('..\\')
1070
- ) {
1071
- return pathToFileURL(resolve(cwd, specifier)).href;
1207
+ function throwIfAborted(signal) {
1208
+ if (signal?.aborted) {
1209
+ throw signal.reason ?? new Error('operation aborted');
1072
1210
  }
1073
- return specifier;
1074
1211
  }
1075
1212
 
1076
- // PBCLI-37: the absolute file path of a filesystem `<from>` (path or
1077
- // file: URL), or undefined for a bare package specifier — those resolve
1078
- // from the host's own module tree and are neither probed nor provisioned.
1079
- function moduleFilePath(specifier, cwd) {
1080
- if (specifier.startsWith('file:')) return fileURLToPath(specifier);
1081
- if (
1082
- isAbsolute(specifier) ||
1083
- specifier.startsWith('./') ||
1084
- specifier.startsWith('../') ||
1085
- specifier.startsWith('.\\') ||
1086
- specifier.startsWith('..\\')
1087
- ) {
1088
- return resolve(cwd, specifier);
1213
+ function registryPreparer(args, options, stderr) {
1214
+ return (
1215
+ options.prepareRegistryModule ??
1216
+ prepareConfiguredRegistries({
1217
+ enabled: !args.noProvision,
1218
+ stderr,
1219
+ hostRoots: options.hostRoots,
1220
+ commandName: 'playbook run',
1221
+ })
1222
+ );
1223
+ }
1224
+
1225
+ function createAttemptId(options) {
1226
+ const attemptId = (options.createAttemptId ?? randomUUID)();
1227
+ if (typeof attemptId !== 'string' || !UUID_PATTERN.test(attemptId)) {
1228
+ throw new Error(
1229
+ `uncertain turn attempt id generator returned a non-UUID value: ${JSON.stringify(attemptId)}`,
1230
+ );
1089
1231
  }
1090
- return undefined;
1232
+ return attemptId;
1091
1233
  }
1092
1234
 
1093
- // PBCLI-36/37: probe-and-provision for a filesystem registry module.
1094
- // Returns {} to proceed or { code } after a reported provisioning fault.
1095
- async function maybeProvision(specifier, args, ctx) {
1096
- const modulePath = moduleFilePath(specifier, ctx.cwdDefault);
1097
- if (modulePath === undefined) return {};
1098
- return provisionEngine({
1099
- modulePath,
1100
- stderr: ctx.stderr,
1101
- enabled: !args.noProvision,
1102
- hostRoots: ctx.hostRoots,
1103
- });
1235
+ async function releaseLease(lease) {
1236
+ if (lease === undefined) return undefined;
1237
+ try {
1238
+ await lease.release();
1239
+ return undefined;
1240
+ } catch (error) {
1241
+ return error;
1242
+ }
1104
1243
  }
1105
1244
 
1106
- function isValidRegistryEntry(value) {
1107
- return (
1108
- typeof value === 'object' &&
1109
- value !== null &&
1110
- typeof value.id === 'string' &&
1111
- typeof value.command === 'string' &&
1112
- typeof value.intent === 'string' &&
1113
- Array.isArray(value.requiredRoleIds) &&
1114
- typeof value.validateOptions === 'function' &&
1115
- typeof value.createRuntime === 'function'
1245
+ async function reportUncertainSession(stderr, sessionId) {
1246
+ await writeStream(
1247
+ stderr,
1248
+ [
1249
+ `playbook run: Captain session ${JSON.stringify(sessionId)} has an uncertain turn and will not be replayed automatically`,
1250
+ 'Retry may duplicate external effects from the interrupted attempt; discard abandons that attempted turn.',
1251
+ `playbook run --session ${sessionId} --retry-uncertain`,
1252
+ `playbook run --session ${sessionId} --discard-uncertain`,
1253
+ '',
1254
+ ].join('\n'),
1116
1255
  );
1117
1256
  }
1118
1257
 
1258
+ function replayInvocation(argv, args, input) {
1259
+ if (args.retryUncertain || args.discardUncertain) {
1260
+ return ['run', ...argv];
1261
+ }
1262
+ if (args.input !== undefined) return ['run', ...argv];
1263
+ return args.terminated
1264
+ ? ['run', ...argv, input]
1265
+ : ['run', ...argv, '--', input];
1266
+ }
1267
+
1268
+ function cloneJson(value) {
1269
+ return JSON.parse(JSON.stringify(value));
1270
+ }
1271
+
1119
1272
  async function readAllStdin() {
1120
1273
  const chunks = [];
1121
- for await (const chunk of process.stdin) chunks.push(chunk);
1274
+ for await (const chunk of process.stdin) {
1275
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
1276
+ }
1122
1277
  return Buffer.concat(chunks).toString('utf8');
1123
1278
  }
1124
1279
 
1125
- function runHelpText() {
1280
+ async function writeStream(stream, text) {
1281
+ const ready = stream.write(text);
1282
+ if (ready !== false || typeof stream.once !== 'function') return;
1283
+ await new Promise((resolvePromise, rejectPromise) => {
1284
+ const onDrain = () => {
1285
+ stream.off?.('error', onError);
1286
+ resolvePromise();
1287
+ };
1288
+ const onError = (error) => {
1289
+ stream.off?.('drain', onDrain);
1290
+ rejectPromise(error);
1291
+ };
1292
+ stream.once('drain', onDrain);
1293
+ stream.once('error', onError);
1294
+ });
1295
+ }
1296
+
1297
+ function runHelpText(userConfigPath) {
1126
1298
  return [
1127
1299
  'Usage:',
1128
- ' playbook run <from> [task] [options]',
1129
- ' playbook run resume <session-id> [reply] [options]',
1130
- ' playbook run resume --last [reply] [options]',
1300
+ ' playbook run [--with <path>]... [--no-provision] [--json]',
1301
+ ' [--verbose] [--] [input]',
1302
+ ' playbook run (--continue | --session <id>) [--with <path>]...',
1303
+ ' [--no-provision] [--json] [--verbose] [--] [reply]',
1304
+ ' playbook run --session <id> --retry-uncertain [--no-provision]',
1305
+ ' playbook run --session <id> --discard-uncertain',
1131
1306
  '',
1132
- ' <from> registry module specifier (package subpath, path, or file: URL)',
1133
- ' [task] Boss intent; read from stdin when omitted',
1134
- ' [reply] Boss reply to a parked session; read from stdin when omitted',
1135
- ' -- end of options; use before a task or reply that starts with -',
1307
+ ' [input] one exact Boss message; read verbatim from stdin when omitted',
1308
+ ' -- end options so a flag-shaped input remains Boss text',
1136
1309
  '',
1137
- 'Options:',
1138
- ' --player <role>=<agent> bind a required role (repeatable)',
1139
- ' --captain <agent> set the captain/judge agent',
1140
- ' --option <key>=<value> playbook option slice (repeatable)',
1141
- ' --cwd <dir> agents working directory',
1142
- ' --json print one JSON envelope (outcome, sessionId,',
1143
- ' output or questions) instead of plain text',
1144
- ' --last resume the most recently parked session',
1145
- ' --verbose forward telemetry topics to stderr',
1146
- ' --no-provision never create engine links beside a',
1147
- ' filesystem <from> module',
1148
- ' -h, --help print this help',
1310
+ `Default config: ${userConfigPath}`,
1149
1311
  '',
1150
- ' <agent> is <adapter>[:<model>][@<effort>] over the shorthands',
1151
- ' claude, codex, gemini, opencode e.g. codex:gpt-5.5@xhigh, or',
1152
- ' claude@high for the default model at high effort. Every role and',
1153
- ' the captain default to claude, unless a top-level run: block in',
1154
- ' ${XDG_CONFIG_HOME:-$HOME/.config}/playbook/playbook.config.yaml',
1155
- ' supplies defaults run.captain, run.players.<role>, or the',
1156
- ' run.player catch-all for other roles; flags override per role.',
1312
+ 'A new run uses the same configured Captain, enabled playbooks, players,',
1313
+ 'options, overlays, provisioning, and readiness gate as interactive',
1314
+ '`playbook`. Enable an external registry in that config, then invoke its',
1315
+ 'effective /command through Captain. The former positional registry,',
1316
+ 'resume, and run-only binding surfaces have been removed.',
1317
+ 'Stable agents live under top-level players; every playbook-local role',
1318
+ 'binds explicitly under playbooks.<id>.roles. Equal player ids share one',
1319
+ 'provider conversation; distinct ids remain isolated.',
1320
+ 'Legacy playbooks.<id>.players is rejected and is not auto-migrated,',
1321
+ 'because choosing new ids decides sharing versus isolation.',
1322
+ 'An ordinary continued run restores the stored structure and working',
1323
+ 'directory, then reads current config and overlays for model and effort.',
1324
+ 'Uncertain retry instead uses its exact recorded input and settings.',
1157
1325
  '',
1158
- ' When a playbook needs a Boss reply, the run prints the question,',
1159
- ' parks the session under',
1160
- ' ${XDG_STATE_HOME:-$HOME/.local/state}/playbook/sessions, and exits 3;',
1161
- ' answer with `playbook run resume`. Bindings are stored with the',
1162
- ' session, so resume takes no --player/--captain/--option/--cwd.',
1326
+ 'Options:',
1327
+ ' --with <path> overlay a generic config fragment (repeatable)',
1328
+ ' --no-provision do not provision thin filesystem registry engines',
1329
+ ' --continue reply to the latest durable Captain session',
1330
+ ' --session <id> reply to one durable Captain session UUID',
1331
+ ' --retry-uncertain retry that session\'s exact recorded uncertain input',
1332
+ ' --discard-uncertain discard that session\'s uncertain attempt',
1333
+ ' --json print exactly {"sessionId", "reply"}',
1334
+ ' --verbose print Captain telemetry topics to stderr',
1335
+ ' -h, --help print this help without reading input or config',
1163
1336
  '',
1164
1337
  ].join('\n');
1165
1338
  }