@sublang/playbook 0.9.0 → 1.3.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 (51) hide show
  1. package/README.md +190 -151
  2. package/package.json +50 -6
  3. package/reference/sdlc/captain.md +102 -0
  4. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +227 -0
  5. package/reference/sdlc/captain.playbook/captain.fsm.js +628 -0
  6. package/reference/sdlc/captain.playbook/captain.fsm.ts +851 -0
  7. package/reference/sdlc/captain.playbook/captain.gears.md +60 -0
  8. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +23 -0
  9. package/reference/sdlc/captain.playbook/captain.playbook.js +1053 -0
  10. package/reference/sdlc/captain.playbook/captain.playbook.ts +1144 -0
  11. package/reference/sdlc/code.playbook/bin/playbook.js +158 -12
  12. package/reference/sdlc/code.playbook/bin/run.js +999 -0
  13. package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -4
  14. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +2 -2
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +1 -1
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +6 -6
  17. package/reference/sdlc/code.playbook/code.fsm.js +334 -102
  18. package/reference/sdlc/code.playbook/code.fsm.ts +467 -180
  19. package/reference/sdlc/code.playbook/code.gears.md +11 -10
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +16 -19
  21. package/reference/sdlc/code.playbook/code.playbook.js +199 -488
  22. package/reference/sdlc/code.playbook/code.playbook.ts +327 -566
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +0 -3
  24. package/reference/sdlc/code.playbook/code.registry.js +0 -3
  25. package/reference/sdlc/code.playbook/code.registry.ts +0 -6
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +9 -4
  27. package/reference/sdlc/code.playbook/playbook-captain.js +889 -210
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1136 -257
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +21 -0
  30. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +396 -0
  31. package/reference/sdlc/discuss.playbook/discuss.fsm.js +2066 -0
  32. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +2464 -0
  33. package/reference/sdlc/discuss.playbook/discuss.gears.md +251 -0
  34. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +113 -0
  35. package/reference/sdlc/discuss.playbook/discuss.playbook.js +1514 -0
  36. package/reference/sdlc/discuss.playbook/discuss.playbook.ts +1926 -0
  37. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +58 -0
  38. package/reference/sdlc/discuss.playbook/discuss.registry.js +97 -0
  39. package/reference/sdlc/discuss.playbook/discuss.registry.ts +153 -0
  40. package/slc/gears2fsm.md +557 -57
  41. package/slc/link.md +1165 -89
  42. package/slc/optimize.md +92 -0
  43. package/slc/text2gears.md +255 -7
  44. package/src/runtime.d.ts +146 -3
  45. package/src/runtime.ts +201 -2
  46. package/src/xstate-playbook-runtime.d.ts +201 -0
  47. package/src/xstate-playbook-runtime.js +2058 -0
  48. package/src/xstate-playbook-runtime.ts +2792 -0
  49. package/src/xstate-runtime.d.ts +95 -0
  50. package/src/xstate-runtime.js +1258 -0
  51. package/src/xstate-runtime.ts +1816 -0
@@ -0,0 +1,999 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
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.
12
+
13
+ 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
+ import { homedir } from 'node:os';
23
+ import { isAbsolute, join, resolve } from 'node:path';
24
+ import { pathToFileURL } from 'node:url';
25
+ import {
26
+ Cligent,
27
+ isEffortSupported,
28
+ supportedEffortValues,
29
+ } from '@sublang/cligent';
30
+ import { parse as parseYaml } from 'yaml';
31
+
32
+ // PBCLI-19: adapter shorthands the run host can construct.
33
+ const ADAPTER_LOADERS = {
34
+ claude: async () =>
35
+ (await import('@sublang/cligent/adapters/claude-code')).ClaudeCodeAdapter,
36
+ codex: async () => (await import('@sublang/cligent/adapters/codex')).CodexAdapter,
37
+ gemini: async () =>
38
+ (await import('@sublang/cligent/adapters/gemini')).GeminiAdapter,
39
+ opencode: async () =>
40
+ (await import('@sublang/cligent/adapters/opencode')).OpenCodeAdapter,
41
+ };
42
+
43
+ const DEFAULT_ADAPTER = 'claude';
44
+ // PBCLI-18: exit-code map — terminal 0, arg/import 1, failed/aborted 2,
45
+ // suspended/quiescent 3.
46
+ const EXIT = { terminal: 0, arg: 1, failed: 2, suspended: 3 };
47
+ // PBCLI-23: session-file schema version for the park/resume store.
48
+ const SESSION_STORE_VERSION = 1;
49
+ // PBCLI-23: a session id names a file inside the store; anything with a
50
+ // path separator (or any character a fresh UUID cannot contain) would
51
+ // escape the 0700-protected directory and must be rejected before join.
52
+ const SESSION_REF_PATTERN = /^[A-Za-z0-9-]+$/;
53
+
54
+ export async function runPlaybookRun(options = {}) {
55
+ const argv = options.argv ?? [];
56
+ const stdout = options.stdout ?? process.stdout;
57
+ const stderr = options.stderr ?? process.stderr;
58
+ const cwdDefault = options.cwd ?? process.cwd();
59
+ const loadModule =
60
+ options.loadModule ??
61
+ ((specifier) => import(registryImportSpecifier(specifier, cwdDefault)));
62
+ const createAgent = options.createAgent ?? defaultCreateAgent;
63
+ const readStdin = options.readStdin ?? readAllStdin;
64
+ const sessionsDir = options.sessionsDir ?? defaultSessionsDir(process.env);
65
+ // PBCLI-28/29: config defaults come from the same user config file the
66
+ // interactive launcher resolves; tests inject a hermetic path.
67
+ const userConfigPath =
68
+ options.userConfigPath ?? (await defaultUserConfigPath());
69
+ const ctx = {
70
+ stdout,
71
+ stderr,
72
+ cwdDefault,
73
+ loadModule,
74
+ createAgent,
75
+ readStdin,
76
+ sessionsDir,
77
+ userConfigPath,
78
+ };
79
+
80
+ let args;
81
+ try {
82
+ args = parseRunArgs(argv);
83
+ } catch (error) {
84
+ stderr.write(`playbook run: ${message(error)}\n`);
85
+ return { code: EXIT.arg };
86
+ }
87
+ if (args.help) {
88
+ stdout.write(runHelpText());
89
+ return { code: 0 };
90
+ }
91
+ if (args.resume) return runResume(args, ctx);
92
+ return runFirst(args, ctx);
93
+ }
94
+
95
+ // PBCLI-18/20: the one-shot first run.
96
+ async function runFirst(args, ctx) {
97
+ const { stderr, cwdDefault, readStdin } = ctx;
98
+ if (!args.from) {
99
+ stderr.write('playbook run: missing <from> registry module\n');
100
+ return { code: EXIT.arg };
101
+ }
102
+
103
+ const loaded = await loadRegistryEntry(args.from, ctx);
104
+ if (loaded.code !== undefined) return loaded;
105
+ const { entry } = loaded;
106
+
107
+ let task = args.task;
108
+ if (task === undefined) task = (await readStdin()).trim();
109
+ if (!task) {
110
+ stderr.write('playbook run: empty task; pass it as an argument or on stdin\n');
111
+ return { code: EXIT.arg };
112
+ }
113
+
114
+ // PBCLI-28 (DR-017): config-supplied defaults bind only a first run;
115
+ // runResume rebuilds the lineup stored with the session.
116
+ let runDefaults;
117
+ try {
118
+ runDefaults = await loadRunDefaults(ctx.userConfigPath);
119
+ } catch (error) {
120
+ stderr.write(`playbook run: ${message(error)}\n`);
121
+ return { code: EXIT.arg };
122
+ }
123
+
124
+ // PBCLI-19/28: bind every required role, then the captain — flag over
125
+ // config default over built-in claude. An unrequired run.players role is
126
+ // ignored (the config is global across playbooks); an unrequired
127
+ // --player flag stays an error below.
128
+ const roleSpecs = new Map(
129
+ entry.requiredRoleIds.map((role) => [
130
+ role,
131
+ {
132
+ ...(runDefaults.players.get(role) ??
133
+ runDefaults.player ?? { adapter: DEFAULT_ADAPTER }),
134
+ },
135
+ ]),
136
+ );
137
+ for (const [role, spec] of args.players) {
138
+ if (!roleSpecs.has(role)) {
139
+ stderr.write(`playbook run: --player ${role} is not a required role\n`);
140
+ return { code: EXIT.arg };
141
+ }
142
+ roleSpecs.set(role, spec);
143
+ }
144
+ const captainSpec =
145
+ args.captain ?? runDefaults.captain ?? { adapter: DEFAULT_ADAPTER };
146
+ const specError = specsDiagnostic([...roleSpecs.values(), captainSpec]);
147
+ if (specError !== undefined) {
148
+ stderr.write(`playbook run: ${specError}\n`);
149
+ return { code: EXIT.arg };
150
+ }
151
+
152
+ let runtime;
153
+ try {
154
+ runtime = entry.createRuntime({
155
+ captainOptions: args.option,
156
+ players: playersFromSpecs(roleSpecs),
157
+ });
158
+ } catch (error) {
159
+ stderr.write(`playbook run: ${message(error)}\n`);
160
+ return { code: EXIT.arg };
161
+ }
162
+
163
+ // PBCLI-23: the record stores everything a resume needs to rebuild the
164
+ // identical host. `cwd` is resolved to an absolute path so a resume from
165
+ // another directory rebinds the agents to the same place.
166
+ const store = {
167
+ schemaVersion: SESSION_STORE_VERSION,
168
+ sessionId: randomUUID(),
169
+ playbookId: entry.id,
170
+ from: registryImportSpecifier(args.from, cwdDefault),
171
+ cwd: resolve(cwdDefault, args.cwd ?? '.'),
172
+ captain: captainSpec,
173
+ players: Object.fromEntries(roleSpecs),
174
+ option: args.option,
175
+ };
176
+ return driveTurn({
177
+ ctx,
178
+ runtime,
179
+ store,
180
+ text: task,
181
+ json: args.json,
182
+ verbose: args.verbose,
183
+ restoreFrom: undefined,
184
+ });
185
+ }
186
+
187
+ // PBCLI-22/23: continue a persisted parked session.
188
+ async function runResume(args, ctx) {
189
+ const { stderr, readStdin, sessionsDir } = ctx;
190
+ if (
191
+ args.players.size > 0 ||
192
+ args.captain !== undefined ||
193
+ Object.keys(args.option).length > 0 ||
194
+ args.cwd !== undefined
195
+ ) {
196
+ stderr.write(
197
+ 'playbook run: resume uses the bindings stored with the session; ' +
198
+ 'drop --player/--captain/--option/--cwd\n',
199
+ );
200
+ return { code: EXIT.arg };
201
+ }
202
+
203
+ let sessionFile;
204
+ let record;
205
+ if (args.last) {
206
+ const latest = await latestSessionRecord(sessionsDir);
207
+ if (!latest) {
208
+ stderr.write(`playbook run: no persisted session under ${sessionsDir}\n`);
209
+ return { code: EXIT.arg };
210
+ }
211
+ ({ file: sessionFile, record } = latest);
212
+ } else if (args.sessionRef) {
213
+ if (!SESSION_REF_PATTERN.test(args.sessionRef)) {
214
+ stderr.write(
215
+ `playbook run: "${args.sessionRef}" is not a session id\n`,
216
+ );
217
+ return { code: EXIT.arg };
218
+ }
219
+ sessionFile = join(sessionsDir, `${args.sessionRef}.json`);
220
+ try {
221
+ record = JSON.parse(await readFile(sessionFile, 'utf8'));
222
+ } catch (error) {
223
+ stderr.write(
224
+ `playbook run: cannot read session ${args.sessionRef}: ${message(error)}\n`,
225
+ );
226
+ return { code: EXIT.arg };
227
+ }
228
+ } else {
229
+ stderr.write('playbook run: resume needs a <session-id> or --last\n');
230
+ return { code: EXIT.arg };
231
+ }
232
+ if (!isValidSessionRecord(record)) {
233
+ stderr.write(
234
+ `playbook run: ${sessionFile} is not a schema-version-${SESSION_STORE_VERSION} playbook run session\n`,
235
+ );
236
+ return { code: EXIT.arg };
237
+ }
238
+
239
+ let reply = args.task;
240
+ if (reply === undefined) reply = (await readStdin()).trim();
241
+ if (!reply) {
242
+ stderr.write(
243
+ 'playbook run: empty reply; pass it as an argument or on stdin\n',
244
+ );
245
+ return { code: EXIT.arg };
246
+ }
247
+
248
+ const loaded = await loadRegistryEntry(record.from, ctx);
249
+ if (loaded.code !== undefined) return loaded;
250
+ const { entry } = loaded;
251
+ // PBCLI-23: the module may have changed since the session parked; a
252
+ // different playbook id means a different machine, which the stored
253
+ // snapshot cannot rehydrate.
254
+ if (entry.id !== record.playbookId) {
255
+ stderr.write(
256
+ `playbook run: ${record.from} now exposes playbook "${entry.id}", ` +
257
+ `but the stored session belongs to "${record.playbookId}"\n`,
258
+ );
259
+ return { code: EXIT.arg };
260
+ }
261
+ const roleSpecs = new Map(Object.entries(record.players));
262
+ const missingRole = entry.requiredRoleIds.find(
263
+ (role) => !roleSpecs.has(role),
264
+ );
265
+ if (missingRole !== undefined) {
266
+ stderr.write(
267
+ `playbook run: stored session lacks required role "${missingRole}"; ` +
268
+ `the ${record.from} module changed since the session parked\n`,
269
+ );
270
+ return { code: EXIT.arg };
271
+ }
272
+ const specError = specsDiagnostic([...roleSpecs.values(), record.captain]);
273
+ if (specError !== undefined) {
274
+ stderr.write(`playbook run: ${specError}\n`);
275
+ return { code: EXIT.arg };
276
+ }
277
+
278
+ let runtime;
279
+ try {
280
+ runtime = entry.createRuntime({
281
+ captainOptions: record.option,
282
+ players: playersFromSpecs(roleSpecs),
283
+ });
284
+ } catch (error) {
285
+ stderr.write(`playbook run: ${message(error)}\n`);
286
+ return { code: EXIT.arg };
287
+ }
288
+ if (typeof runtime.restore !== 'function') {
289
+ stderr.write(
290
+ `playbook run: the ${record.from} runtime does not support resume\n`,
291
+ );
292
+ return { code: EXIT.arg };
293
+ }
294
+
295
+ return driveTurn({
296
+ ctx,
297
+ runtime,
298
+ store: record,
299
+ text: reply,
300
+ json: args.json,
301
+ verbose: args.verbose,
302
+ restoreFrom: { snapshot: record.snapshot, sessionFile },
303
+ });
304
+ }
305
+
306
+ // PBCLI-18: shared module-load pipeline for `<from>` and a stored resume
307
+ // specifier. Returns { entry } or an { code } failure already reported.
308
+ async function loadRegistryEntry(specifier, { loadModule, stderr }) {
309
+ let entry;
310
+ try {
311
+ entry = (await loadModule(specifier))?.default;
312
+ } catch (cause) {
313
+ stderr.write(
314
+ `playbook run: ${specifier} failed to import: ${message(cause)}\n`,
315
+ );
316
+ return { code: EXIT.arg };
317
+ }
318
+ if (!isValidRegistryEntry(entry)) {
319
+ stderr.write(
320
+ `playbook run: ${specifier} exposes no valid registry entry\n`,
321
+ );
322
+ return { code: EXIT.arg };
323
+ }
324
+ return { entry };
325
+ }
326
+
327
+ // PBCLI-20/23: one Boss turn over the headless cligent-backed ports,
328
+ // parking to the session store when the playbook awaits a Boss reply.
329
+ async function driveTurn({ ctx, runtime, store, text, json, verbose, restoreFrom }) {
330
+ const { stdout, stderr, createAgent } = ctx;
331
+ const { sessionId, cwd } = store;
332
+
333
+ const agentsByRole = new Map();
334
+ for (const [role, spec] of Object.entries(store.players)) {
335
+ agentsByRole.set(role, createAgent({ ...spec, role, cwd }));
336
+ }
337
+ const captainAgent = createAgent({ ...store.captain, role: 'captain', cwd });
338
+
339
+ const controller = new AbortController();
340
+ const ports = {
341
+ async callPlayer(playerId, prompt, signal, callOptions) {
342
+ const agent = agentsByRole.get(playerId);
343
+ if (!agent) return { status: 'error', error: `unknown player ${playerId}` };
344
+ const result = await agent.run(prompt, { resume: callOptions?.resume, signal });
345
+ return toPlayerResult(result);
346
+ },
347
+ async callCaptain(prompt, signal, callOptions) {
348
+ const result = await captainAgent.run(prompt, {
349
+ resume: callOptions?.resume,
350
+ ...(callOptions?.allowedTools === undefined
351
+ ? {}
352
+ : { allowedTools: callOptions.allowedTools }),
353
+ signal,
354
+ });
355
+ return {
356
+ status: result.status,
357
+ ...(result.finalText === undefined
358
+ ? {}
359
+ : { finalText: result.finalText }),
360
+ ...(result.error ? { error: result.error } : {}),
361
+ };
362
+ },
363
+ async callJudge(prompt, signal) {
364
+ const result = await captainAgent.run(prompt, {
365
+ resume: false,
366
+ allowedTools: [],
367
+ signal,
368
+ });
369
+ if (result.status !== 'ok' || result.finalText === undefined) {
370
+ throw new Error(result.error ?? 'judge call failed');
371
+ }
372
+ return result.finalText;
373
+ },
374
+ async callPlaybook() {
375
+ // The one-shot host cannot drive the child, but returning a suspended
376
+ // start lets the linked runtime expose that boundary as outcome
377
+ // `suspended`, which finishRun maps to the documented exit code 3.
378
+ return { state: 'suspended', childSessionId: randomUUID() };
379
+ },
380
+ async emitStatus(statusText) {
381
+ stderr.write(`◇ ${statusText}\n`);
382
+ },
383
+ async emitTelemetry(event) {
384
+ if (verbose) stderr.write(`· ${event.topic}\n`);
385
+ },
386
+ };
387
+
388
+ const session = {
389
+ sessionId,
390
+ playbookId: store.playbookId,
391
+ rootSessionId: sessionId,
392
+ depth: 0,
393
+ ports,
394
+ };
395
+ // DR-014 §2: only a successfully persisted parked hand-off skips
396
+ // disposal; the session is then suspended, not ended.
397
+ let parked = false;
398
+ try {
399
+ if (restoreFrom) {
400
+ try {
401
+ await runtime.restore(session, restoreFrom.snapshot);
402
+ } catch (error) {
403
+ stderr.write(
404
+ `playbook run: session ${sessionId} cannot be resumed: ${message(error)}\n`,
405
+ );
406
+ return { code: EXIT.arg };
407
+ }
408
+ } else {
409
+ await runtime.init(session);
410
+ }
411
+ const result = await runtime.handleBossInput({
412
+ text,
413
+ signal: controller.signal,
414
+ });
415
+ const parkedSnapshot =
416
+ (result.outcome === 'quiescent' || result.outcome === 'no-action') &&
417
+ typeof runtime.exportSnapshot === 'function'
418
+ ? runtime.exportSnapshot()
419
+ : undefined;
420
+ if (parkedSnapshot && parkedSnapshot.pendingBossQuestions.length > 0) {
421
+ const outcome = await finishParked({
422
+ ctx,
423
+ store,
424
+ snapshot: parkedSnapshot,
425
+ json,
426
+ });
427
+ parked = outcome.code === EXIT.suspended;
428
+ return outcome;
429
+ }
430
+ const outcome = finishRun(result, { stdout, stderr, json, sessionId });
431
+ if (restoreFrom && result.outcome === 'terminal') {
432
+ // The turn succeeded; a session-file removal failure must not mask
433
+ // the terminal output or flip the exit code.
434
+ try {
435
+ await rm(restoreFrom.sessionFile, { force: true });
436
+ } catch (error) {
437
+ stderr.write(
438
+ `playbook run: warning: could not remove ${restoreFrom.sessionFile}: ${message(error)}\n`,
439
+ );
440
+ }
441
+ }
442
+ return outcome;
443
+ } catch (error) {
444
+ stderr.write(`playbook run: ${message(error)}\n`);
445
+ return { code: EXIT.failed };
446
+ } finally {
447
+ if (!parked) {
448
+ try {
449
+ await runtime.dispose();
450
+ } catch {
451
+ // A dispose failure must not mask the run's own outcome.
452
+ }
453
+ }
454
+ }
455
+ }
456
+
457
+ // PBCLI-23: persist the parked session and surface the pending question —
458
+ // stdout carries the question text (the run's product), stderr one hint
459
+ // naming the session id and the exact resume command.
460
+ async function finishParked({ ctx, store, snapshot, json }) {
461
+ const { stdout, stderr } = ctx;
462
+ const now = new Date().toISOString();
463
+ const record = {
464
+ ...store,
465
+ createdAt: store.createdAt ?? now,
466
+ updatedAt: now,
467
+ snapshot,
468
+ };
469
+ const file = join(ctx.sessionsDir, `${store.sessionId}.json`);
470
+ try {
471
+ await mkdir(ctx.sessionsDir, { recursive: true, mode: 0o700 });
472
+ // Write-then-rename so a crash mid-write can never truncate the only
473
+ // durable copy of the session.
474
+ const tmpFile = `${file}.${process.pid}.tmp`;
475
+ await writeFile(tmpFile, `${JSON.stringify(record, null, 2)}\n`, {
476
+ mode: 0o600,
477
+ });
478
+ await rename(tmpFile, file);
479
+ } catch (error) {
480
+ stderr.write(`playbook run: cannot persist session: ${message(error)}\n`);
481
+ return { code: EXIT.failed };
482
+ }
483
+ const questions = snapshot.pendingBossQuestions;
484
+ if (json) {
485
+ stdout.write(
486
+ `${JSON.stringify(
487
+ {
488
+ outcome: 'awaiting-reply',
489
+ sessionId: store.sessionId,
490
+ questions: questions.map(({ questionId, player, question }) => ({
491
+ questionId,
492
+ player,
493
+ question,
494
+ })),
495
+ },
496
+ null,
497
+ 2,
498
+ )}\n`,
499
+ );
500
+ } else {
501
+ stdout.write(`${questions.map(({ question }) => question).join('\n')}\n`);
502
+ }
503
+ stderr.write(
504
+ `playbook run: session ${store.sessionId} is awaiting a Boss reply; ` +
505
+ `continue with: playbook run resume ${store.sessionId} "<answer>"\n`,
506
+ );
507
+ return { code: EXIT.suspended };
508
+ }
509
+
510
+ // PBCLI-18: map the single turn's outcome to stdout output and an exit code.
511
+ function finishRun(result, { stdout, stderr, json, sessionId }) {
512
+ switch (result.outcome) {
513
+ case 'terminal':
514
+ stdout.write(
515
+ (json
516
+ ? JSON.stringify(
517
+ {
518
+ outcome: 'terminal',
519
+ sessionId,
520
+ output: result.output ?? null,
521
+ },
522
+ null,
523
+ 2,
524
+ )
525
+ : renderOutput(result.output)) + '\n',
526
+ );
527
+ return { code: EXIT.terminal };
528
+ case 'failed':
529
+ case 'aborted':
530
+ stderr.write(
531
+ `playbook run: ${result.outcome}${
532
+ result.error ? `: ${result.error.message}` : ''
533
+ }\n`,
534
+ );
535
+ return { code: EXIT.failed };
536
+ case 'suspended':
537
+ stderr.write(
538
+ 'playbook run: playbook made a nested call a one-shot run cannot answer\n',
539
+ );
540
+ return { code: EXIT.suspended };
541
+ default:
542
+ // quiescent / no-action without a persistable pending question:
543
+ // the pre-DR-014 diagnostic path.
544
+ stderr.write(
545
+ 'playbook run: playbook is awaiting Boss input; a one-shot run cannot continue\n',
546
+ );
547
+ return { code: EXIT.suspended };
548
+ }
549
+ }
550
+
551
+ function renderOutput(output) {
552
+ if (output === null || output === undefined) return '';
553
+ if (typeof output === 'string') return output;
554
+ if (
555
+ typeof output === 'object' &&
556
+ typeof (output.response ?? output.finalText) === 'string'
557
+ ) {
558
+ return output.response ?? output.finalText;
559
+ }
560
+ return JSON.stringify(output);
561
+ }
562
+
563
+ function toPlayerResult(result) {
564
+ return {
565
+ status: result.status,
566
+ ...(result.finalText === undefined ? {} : { finalText: result.finalText }),
567
+ ...(result.resumeToken ? { resumeToken: result.resumeToken } : {}),
568
+ ...(result.error ? { error: result.error } : {}),
569
+ };
570
+ }
571
+
572
+ function playersFromSpecs(roleSpecs) {
573
+ return [...roleSpecs].map(([role, spec]) => ({
574
+ id: role,
575
+ adapter: spec.adapter,
576
+ ...(spec.model ? { model: spec.model } : {}),
577
+ }));
578
+ }
579
+
580
+ // PBCLI-19/26: returns a diagnostic for the first invalid spec — an
581
+ // unknown adapter or an effort the adapter does not support — or
582
+ // undefined when every spec resolves. The caller must compare against
583
+ // undefined.
584
+ function specsDiagnostic(specs) {
585
+ for (const spec of specs) {
586
+ if (
587
+ !isAgentSpec(spec) ||
588
+ !Object.prototype.hasOwnProperty.call(ADAPTER_LOADERS, spec.adapter)
589
+ ) {
590
+ const adapter = isAgentSpec(spec)
591
+ ? spec.adapter
592
+ : String(spec?.adapter);
593
+ return `unknown adapter "${adapter}"`;
594
+ }
595
+ if (spec.effort !== undefined && !isEffortSupported(spec.adapter, spec.effort)) {
596
+ const supported = supportedEffortValues(spec.adapter).join(', ');
597
+ return `adapter "${spec.adapter}" does not support effort "${spec.effort}" (supported: ${supported})`;
598
+ }
599
+ }
600
+ return undefined;
601
+ }
602
+
603
+ function isAgentSpec(spec) {
604
+ return (
605
+ typeof spec === 'object' &&
606
+ spec !== null &&
607
+ typeof spec.adapter === 'string' &&
608
+ spec.adapter.length > 0 &&
609
+ (spec.model === undefined || typeof spec.model === 'string') &&
610
+ (spec.effort === undefined ||
611
+ (typeof spec.effort === 'string' && spec.effort.length > 0))
612
+ );
613
+ }
614
+
615
+ // PBCLI-29: the run host reads the same user config file the interactive
616
+ // launcher resolves. The resolver is imported lazily: a static import of
617
+ // ./playbook.js would deadlock the CLI entry — playbook.js is still
618
+ // mid-evaluation of its own top-level await when it dynamically imports
619
+ // this module, and a circular static edge back to it can never settle.
620
+ // The launcher always injects userConfigPath, so this default runs only
621
+ // for direct runPlaybookRun callers, where playbook.js is not evaluating.
622
+ async function defaultUserConfigPath() {
623
+ const { resolveUserConfigPath } = await import('./playbook.js');
624
+ return resolveUserConfigPath(process.env, process.env.HOME ?? homedir());
625
+ }
626
+
627
+ // PBCLI-28/29 (DR-017): default agent specs for a first run, read from the
628
+ // user config's top-level `run` map. An absent file or absent map is an
629
+ // empty default set; a malformed file or block fails closed — the run must
630
+ // never silently bind different agents than the user configured. Adapter
631
+ // and effort support of the specs actually bound flow through the shared
632
+ // specsDiagnostic path.
633
+ async function loadRunDefaults(userConfigPath) {
634
+ const defaults = { players: new Map() };
635
+ let text;
636
+ try {
637
+ text = await readFile(userConfigPath, 'utf8');
638
+ } catch (error) {
639
+ if (error?.code === 'ENOENT') return defaults;
640
+ throw new Error(`cannot read config ${userConfigPath}: ${message(error)}`);
641
+ }
642
+ let config;
643
+ try {
644
+ config = parseYaml(text);
645
+ } catch (error) {
646
+ throw new Error(`cannot parse config ${userConfigPath}: ${message(error)}`);
647
+ }
648
+ const run = isPlainMap(config) ? config.run : undefined;
649
+ if (run === undefined || run === null) return defaults;
650
+ if (!isPlainMap(run)) {
651
+ throw new Error(`${userConfigPath}: run must be a map of agent defaults`);
652
+ }
653
+ if (run.captain !== undefined) {
654
+ defaults.captain = parseAgentDefault(run.captain, 'run.captain', userConfigPath);
655
+ }
656
+ if (run.player !== undefined) {
657
+ defaults.player = parseAgentDefault(run.player, 'run.player', userConfigPath);
658
+ }
659
+ if (run.players !== undefined && run.players !== null) {
660
+ if (!isPlainMap(run.players)) {
661
+ throw new Error(
662
+ `${userConfigPath}: run.players must be a map of <role>: <agent>`,
663
+ );
664
+ }
665
+ for (const [role, value] of Object.entries(run.players)) {
666
+ defaults.players.set(
667
+ role,
668
+ parseAgentDefault(value, `run.players.${role}`, userConfigPath),
669
+ );
670
+ }
671
+ }
672
+ return defaults;
673
+ }
674
+
675
+ function parseAgentDefault(value, key, userConfigPath) {
676
+ if (typeof value !== 'string' || value.length === 0) {
677
+ throw new Error(
678
+ `${userConfigPath}: ${key} must be an <adapter>[:<model>][@<effort>] string`,
679
+ );
680
+ }
681
+ try {
682
+ return parseAgent(value);
683
+ } catch (error) {
684
+ throw new Error(`${userConfigPath}: ${key}: ${message(error)}`);
685
+ }
686
+ }
687
+
688
+ function isPlainMap(value) {
689
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
690
+ }
691
+
692
+ // PBCLI-23: the session store honors XDG_STATE_HOME at invocation time.
693
+ export function defaultSessionsDir(env = process.env) {
694
+ const stateHome =
695
+ typeof env.XDG_STATE_HOME === 'string' && env.XDG_STATE_HOME.trim() !== ''
696
+ ? env.XDG_STATE_HOME
697
+ : join(homedir(), '.local', 'state');
698
+ return join(stateHome, 'playbook', 'sessions');
699
+ }
700
+
701
+ // PBCLI-23: `--last` selects by the record's own update timestamp, not
702
+ // filesystem mtime. Unreadable or foreign .json files are skipped.
703
+ async function latestSessionRecord(sessionsDir) {
704
+ let names;
705
+ try {
706
+ names = await readdir(sessionsDir);
707
+ } catch {
708
+ return undefined;
709
+ }
710
+ const candidates = await Promise.all(
711
+ names
712
+ .filter((name) => name.endsWith('.json'))
713
+ .map(async (name) => {
714
+ const file = join(sessionsDir, name);
715
+ try {
716
+ const record = JSON.parse(await readFile(file, 'utf8'));
717
+ if (!isValidSessionRecord(record)) return undefined;
718
+ if (typeof record.updatedAt !== 'string') return undefined;
719
+ return { file, record };
720
+ } catch {
721
+ return undefined;
722
+ }
723
+ }),
724
+ );
725
+ let latest;
726
+ for (const candidate of candidates) {
727
+ if (!candidate) continue;
728
+ if (!latest || candidate.record.updatedAt > latest.record.updatedAt) {
729
+ latest = candidate;
730
+ }
731
+ }
732
+ return latest;
733
+ }
734
+
735
+ function isValidSessionRecord(record) {
736
+ return (
737
+ typeof record === 'object' &&
738
+ record !== null &&
739
+ record.schemaVersion === SESSION_STORE_VERSION &&
740
+ typeof record.sessionId === 'string' &&
741
+ SESSION_REF_PATTERN.test(record.sessionId) &&
742
+ typeof record.playbookId === 'string' &&
743
+ typeof record.from === 'string' &&
744
+ typeof record.cwd === 'string' &&
745
+ isAgentSpec(record.captain) &&
746
+ typeof record.players === 'object' &&
747
+ record.players !== null &&
748
+ Object.values(record.players).every(isAgentSpec) &&
749
+ typeof record.option === 'object' &&
750
+ record.option !== null &&
751
+ typeof record.snapshot === 'object' &&
752
+ record.snapshot !== null
753
+ );
754
+ }
755
+
756
+ // PBCLI-20: default agent — one lazily-built Cligent per role, run through
757
+ // the same event drain as tmux-play's host.
758
+ function defaultCreateAgent({ adapter, model, effort, cwd, role }) {
759
+ let cligent;
760
+ return {
761
+ async run(prompt, callOptions) {
762
+ if (!cligent) {
763
+ const AdapterClass = await ADAPTER_LOADERS[adapter]();
764
+ // Protected auto mode (as the seeded lineup uses, PBCLI-11) so a
765
+ // one-shot run does not block on routine approval prompts.
766
+ cligent = new Cligent(new AdapterClass(), {
767
+ cwd,
768
+ role,
769
+ permissions: { mode: 'auto' },
770
+ ...(model ? { model } : {}),
771
+ ...(effort ? { effort } : {}),
772
+ });
773
+ }
774
+ return runCligentCall(cligent, prompt, callOptions);
775
+ },
776
+ };
777
+ }
778
+
779
+ export async function runCligentCall(cligent, prompt, callOptions = {}) {
780
+ const { resume, allowedTools, signal } = callOptions;
781
+ const gen = cligent.run(prompt, {
782
+ ...(signal ? { abortSignal: signal } : {}),
783
+ ...(resume !== undefined ? { resume } : {}),
784
+ ...(allowedTools !== undefined ? { allowedTools: [...allowedTools] } : {}),
785
+ });
786
+ const textParts = [];
787
+ let done;
788
+ let lastError;
789
+ let completed = false;
790
+ try {
791
+ for (;;) {
792
+ let next;
793
+ try {
794
+ next = await gen.next();
795
+ } catch (error) {
796
+ return { status: signal?.aborted ? 'aborted' : 'error', error: message(error) };
797
+ }
798
+ if (next.done) {
799
+ completed = true;
800
+ break;
801
+ }
802
+ const event = next.value;
803
+ if (event.type === 'text' && typeof event.payload?.content === 'string') {
804
+ textParts.push(event.payload.content);
805
+ } else if (
806
+ event.type === 'text_delta' &&
807
+ typeof event.payload?.delta === 'string'
808
+ ) {
809
+ textParts.push(event.payload.delta);
810
+ }
811
+ if (event.type === 'error') lastError = event.payload?.message;
812
+ if (event.type === 'done') done = event.payload;
813
+ }
814
+ } finally {
815
+ if (!completed) {
816
+ try {
817
+ await gen.return(undefined);
818
+ } catch {
819
+ // The original outcome is already captured.
820
+ }
821
+ }
822
+ }
823
+ const status = done ? mapStatus(done.status) : 'error';
824
+ const finalText = done?.result ?? (textParts.length > 0 ? textParts.join('') : undefined);
825
+ return {
826
+ status,
827
+ finalText,
828
+ ...(done?.resumeToken ? { resumeToken: done.resumeToken } : {}),
829
+ ...(status === 'error'
830
+ ? { error: done?.result ?? lastError ?? 'agent run failed' }
831
+ : {}),
832
+ };
833
+ }
834
+
835
+ function mapStatus(doneStatus) {
836
+ if (doneStatus === 'success') return 'ok';
837
+ if (doneStatus === 'interrupted') return 'aborted';
838
+ return 'error';
839
+ }
840
+
841
+ export function parseRunArgs(argv) {
842
+ const args = {
843
+ from: undefined,
844
+ task: undefined,
845
+ resume: false,
846
+ sessionRef: undefined,
847
+ last: false,
848
+ players: new Map(),
849
+ captain: undefined,
850
+ option: {},
851
+ cwd: undefined,
852
+ json: false,
853
+ verbose: false,
854
+ help: false,
855
+ };
856
+ const positionals = [];
857
+ for (let i = 0; i < argv.length; i += 1) {
858
+ const arg = argv[i];
859
+ if (arg === '--help' || arg === '-h') args.help = true;
860
+ else if (arg === '--json') args.json = true;
861
+ else if (arg === '--verbose') args.verbose = true;
862
+ else if (arg === '--last') args.last = true;
863
+ else if (arg === '--cwd') args.cwd = takeValue(argv, (i += 1), '--cwd');
864
+ else if (arg === '--captain')
865
+ args.captain = parseAgent(takeValue(argv, (i += 1), '--captain'));
866
+ else if (arg === '--player') {
867
+ const [role, agent] = takePair(takeValue(argv, (i += 1), '--player'), '--player');
868
+ args.players.set(role, parseAgent(agent));
869
+ } else if (arg === '--option') {
870
+ const [key, value] = takePair(takeValue(argv, (i += 1), '--option'), '--option');
871
+ args.option[key] = value;
872
+ } else if (arg.startsWith('-')) {
873
+ throw new Error(`unknown option ${arg}`);
874
+ } else positionals.push(arg);
875
+ }
876
+ // PBCLI-22: `playbook run resume <session-id>|--last [reply]`.
877
+ if (positionals[0] === 'resume') {
878
+ args.resume = true;
879
+ let rest = positionals.slice(1);
880
+ if (!args.last) {
881
+ args.sessionRef = rest[0];
882
+ rest = rest.slice(1);
883
+ }
884
+ if (rest.length > 0) args.task = rest.join(' ');
885
+ return args;
886
+ }
887
+ if (args.last) throw new Error('--last applies to `playbook run resume`');
888
+ args.from = positionals[0];
889
+ if (positionals.length > 1) args.task = positionals.slice(1).join(' ');
890
+ return args;
891
+ }
892
+
893
+ // PBCLI-19: `<agent>` is `<adapter>[:<model>][@<effort>]`. The effort
894
+ // rides after the last `@` so a model name may itself contain colons
895
+ // (`opencode:ollama/llama3:8b@max`); `claude@high` keeps the default
896
+ // model while setting effort.
897
+ function parseAgent(value) {
898
+ const at = value.lastIndexOf('@');
899
+ const spec = at === -1 ? value : value.slice(0, at);
900
+ const effort = at === -1 ? undefined : value.slice(at + 1);
901
+ if (at !== -1 && !effort) {
902
+ throw new Error(`agent "${value}" has an empty effort after '@'`);
903
+ }
904
+ const colon = spec.indexOf(':');
905
+ const adapter = colon === -1 ? spec : spec.slice(0, colon);
906
+ const model = colon === -1 ? undefined : spec.slice(colon + 1);
907
+ return {
908
+ adapter,
909
+ ...(model ? { model } : {}),
910
+ ...(effort ? { effort } : {}),
911
+ };
912
+ }
913
+
914
+ function takeValue(argv, index, flag) {
915
+ const value = argv[index];
916
+ if (value === undefined) throw new Error(`${flag} needs a value`);
917
+ return value;
918
+ }
919
+
920
+ function takePair(value, flag) {
921
+ const eq = value.indexOf('=');
922
+ if (eq <= 0) throw new Error(`${flag} needs <key>=<value>`);
923
+ return [value.slice(0, eq), value.slice(eq + 1)];
924
+ }
925
+
926
+ function registryImportSpecifier(specifier, cwd) {
927
+ if (
928
+ isAbsolute(specifier) ||
929
+ specifier.startsWith('./') ||
930
+ specifier.startsWith('../') ||
931
+ specifier.startsWith('.\\') ||
932
+ specifier.startsWith('..\\')
933
+ ) {
934
+ return pathToFileURL(resolve(cwd, specifier)).href;
935
+ }
936
+ return specifier;
937
+ }
938
+
939
+ function isValidRegistryEntry(value) {
940
+ return (
941
+ typeof value === 'object' &&
942
+ value !== null &&
943
+ typeof value.id === 'string' &&
944
+ typeof value.command === 'string' &&
945
+ typeof value.intent === 'string' &&
946
+ Array.isArray(value.requiredRoleIds) &&
947
+ typeof value.validateOptions === 'function' &&
948
+ typeof value.createRuntime === 'function'
949
+ );
950
+ }
951
+
952
+ async function readAllStdin() {
953
+ const chunks = [];
954
+ for await (const chunk of process.stdin) chunks.push(chunk);
955
+ return Buffer.concat(chunks).toString('utf8');
956
+ }
957
+
958
+ function runHelpText() {
959
+ return [
960
+ 'Usage:',
961
+ ' playbook run <from> [task] [options]',
962
+ ' playbook run resume <session-id> [reply] [options]',
963
+ ' playbook run resume --last [reply] [options]',
964
+ '',
965
+ ' <from> registry module specifier (package subpath, path, or file: URL)',
966
+ ' [task] Boss intent; read from stdin when omitted',
967
+ ' [reply] Boss reply to a parked session; read from stdin when omitted',
968
+ '',
969
+ 'Options:',
970
+ ' --player <role>=<agent> bind a required role (repeatable)',
971
+ ' --captain <agent> set the captain/judge agent',
972
+ ' --option <key>=<value> playbook option slice (repeatable)',
973
+ ' --cwd <dir> agents working directory',
974
+ ' --json print one JSON envelope (outcome, sessionId,',
975
+ ' output or questions) instead of plain text',
976
+ ' --last resume the most recently parked session',
977
+ ' --verbose forward telemetry topics to stderr',
978
+ ' -h, --help print this help',
979
+ '',
980
+ ' <agent> is <adapter>[:<model>][@<effort>] over the shorthands',
981
+ ' claude, codex, gemini, opencode — e.g. codex:gpt-5.5@xhigh, or',
982
+ ' claude@high for the default model at high effort. Every role and',
983
+ ' the captain default to claude, unless a top-level run: block in',
984
+ ' ${XDG_CONFIG_HOME:-$HOME/.config}/playbook/playbook.config.yaml',
985
+ ' supplies defaults — run.captain, run.players.<role>, or the',
986
+ ' run.player catch-all for other roles; flags override per role.',
987
+ '',
988
+ ' When a playbook needs a Boss reply, the run prints the question,',
989
+ ' parks the session under',
990
+ ' ${XDG_STATE_HOME:-$HOME/.local/state}/playbook/sessions, and exits 3;',
991
+ ' answer with `playbook run resume`. Bindings are stored with the',
992
+ ' session, so resume takes no --player/--captain/--option/--cwd.',
993
+ '',
994
+ ].join('\n');
995
+ }
996
+
997
+ function message(error) {
998
+ return error instanceof Error ? error.message : String(error);
999
+ }